├── .eslintignore ├── docs ├── requirements.txt ├── assets │ ├── blobs.png │ ├── mainsail.png │ └── pi_imager.png ├── advanced │ ├── developing.md │ ├── cli-arguments.md │ ├── macro-scripting.md │ └── update-manager.md ├── security.md ├── index.md ├── troubleshooting │ └── stuttering-and-blobs.md └── installation.md ├── .gitignore ├── src ├── definitions.d.ts ├── api │ ├── response │ │ ├── Response.ts │ │ ├── ResultResponse.ts │ │ └── ErrorResponse.ts │ ├── notifications │ │ ├── INotification.ts │ │ └── SimpleNotification.ts │ ├── executors │ │ ├── PrinterGcodeHelpExecutor.ts │ │ ├── IMethodExecutor.ts │ │ ├── ServerRestartExecutor.ts │ │ ├── PrinterRestartExecutor.ts │ │ ├── ServerConfigExecutor.ts │ │ ├── MachineProcStatsExecutor.ts │ │ ├── PrinterFirmwareRestartExecutor.ts │ │ ├── AccessOneshotTokenExecutor.ts │ │ ├── MachineUpdateFullExecutor.ts │ │ ├── PrinterObjectsListExecutor.ts │ │ ├── ServerHistoryTotals.ts │ │ ├── MachineUpdateSystemExecutor.ts │ │ ├── ServerDatabaseListExecutor.ts │ │ ├── PrinterEmergencyStopExecutor.ts │ │ ├── ServerFilesListExecutor.ts │ │ ├── PrinterQueryEndstopsStatusExecutor.ts │ │ ├── PrinterPrintPauseExecutor.ts │ │ ├── PrinterPrintResumeExecutor.ts │ │ ├── PrinterPrintCancelExecutor.ts │ │ ├── ServerGcodeStoreExecutor.ts │ │ ├── MachineRebootExecutor.ts │ │ ├── ServerTemperatureStoreExecutor.ts │ │ ├── MachineUpdateClientExecutor.ts │ │ ├── ServerHistoryResetTotals.ts │ │ ├── ServerFilesMetadataExecutor.ts │ │ ├── PrinterGcodeScriptExecutor.ts │ │ ├── MachineShutdownExecutor.ts │ │ ├── ServerAnnouncementsListExecutor.ts │ │ ├── ServerFilesDeleteFileExecutor.ts │ │ ├── MachineUpdateStatusExecutor.ts │ │ ├── MachineServicesRestartExecutor.ts │ │ ├── ServerFilesPostDirectoryExecutor.ts │ │ ├── ServerHistoryDeleteJobExecutor.ts │ │ ├── ServerFilesCopyExecutor.ts │ │ ├── ServerFilesMoveExecutor.ts │ │ ├── PrinterInfoExecutor.ts │ │ ├── ServerHistoryGetJobExecutor.ts │ │ ├── ServerFilesDeleteDirectoryExecutor.ts │ │ ├── MachineServicesStopExecutor.ts │ │ ├── MachineServicesStartExecutor.ts │ │ ├── PrinterPrintStartExecutor.ts │ │ ├── MachineSystemInfoExecutor.ts │ │ ├── ServerHistoryListExecutor.ts │ │ ├── ServerFilesGetDirectoryExecutor.ts │ │ ├── ServerJobQueueStatusExecutor.ts │ │ ├── PrinterObjectsQueryExecutor.ts │ │ ├── ServerConnectionIdentifyExecutor.ts │ │ ├── ServerDatabaseDeleteItemExecutor.ts │ │ ├── ServerDatabasePostItemExecutor.ts │ │ ├── ServerDatabaseGetItemExecutor.ts │ │ ├── PrinterObjectsSubscribeExecutor.ts │ │ └── ServerInfoExecutor.ts │ ├── MessageHandler.ts │ └── http │ │ └── FileHandler.ts ├── printer │ ├── macros │ │ ├── IMacro.ts │ │ ├── PauseMacro.ts │ │ ├── ResumeMacro.ts │ │ ├── StartPrintMacro.ts │ │ ├── CancelPrintMacro.ts │ │ ├── SdcardResetFileMacro.ts │ │ ├── CustomMacro.ts │ │ └── MacroManager.ts │ ├── jobs │ │ ├── IQueuedJob.ts │ │ ├── JobQueue.ts │ │ └── PrintJob.ts │ ├── watchers │ │ ├── Watcher.ts │ │ ├── TemperatureWatcher.ts │ │ └── PositionWatcher.ts │ ├── objects │ │ ├── PauseResumeObject.ts │ │ ├── WebhooksObject.ts │ │ ├── TemperatureObject.ts │ │ ├── FanObject.ts │ │ ├── SystemStatsObject.ts │ │ ├── IdleTimeoutObject.ts │ │ ├── HeatersObject.ts │ │ ├── MotionReportObject.ts │ │ ├── VirtualSdCardObject.ts │ │ ├── PrintStatsObject.ts │ │ ├── GcodeMoveObject.ts │ │ ├── ConfigFileObject.ts │ │ ├── BedMeshObject.ts │ │ ├── ToolheadObject.ts │ │ ├── PrinterObject.ts │ │ └── ObjectManager.ts │ └── HeaterManager.ts ├── files │ ├── IFile.ts │ ├── HashUtils.ts │ ├── IDirectory.ts │ ├── LineReader.ts │ └── FileDirectory.ts ├── util │ ├── NamedObjectMap.ts │ ├── TypedEventEmitter.ts │ ├── Utils.ts │ └── SerialPortSearch.ts ├── auth │ └── AccessManager.ts ├── system │ ├── Network.ts │ ├── SystemInfo.ts │ ├── CpuInfo.ts │ ├── Distribution.ts │ ├── VcgenCmd.ts │ ├── SdInfo.ts │ └── ServiceManager.ts ├── connection │ └── ConnectionManager.ts ├── database │ └── Database.ts ├── update │ ├── Updatable.ts │ ├── ScriptUpdatable.ts │ ├── HttpsRequest.ts │ └── SystemUpdatable.ts ├── logger │ └── Logger.ts ├── compat │ └── KlipperCompat.ts └── Server.ts ├── .dockerignore ├── config ├── marlinraker.toml └── printers │ ├── prusa_i3_mk3s.toml │ ├── prusa_mini.toml │ └── generic.toml ├── tsconfig.json ├── mkdocs.yml ├── Dockerfile ├── README.md ├── CHANGELOG.md ├── cliff.toml ├── .github └── workflows │ └── publish_docker.yml ├── package.json ├── .eslintrc └── scripts └── build.js /.eslintignore: -------------------------------------------------------------------------------- 1 | scripts/ 2 | node_modules/ -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs==1.4.1 2 | mkdocs-material==8.5.7 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules/ 3 | marlinraker_files/ 4 | build/ 5 | dist/ 6 | *.iml 7 | -------------------------------------------------------------------------------- /docs/assets/blobs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phm07/marlinraker/HEAD/docs/assets/blobs.png -------------------------------------------------------------------------------- /docs/assets/mainsail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phm07/marlinraker/HEAD/docs/assets/mainsail.png -------------------------------------------------------------------------------- /docs/assets/pi_imager.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phm07/marlinraker/HEAD/docs/assets/pi_imager.png -------------------------------------------------------------------------------- /src/definitions.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.toml" { 2 | const content: string; 3 | export default content; 4 | } -------------------------------------------------------------------------------- /src/api/response/Response.ts: -------------------------------------------------------------------------------- 1 | abstract class Response { 2 | public toString(): string { 3 | return JSON.stringify(this); 4 | } 5 | } 6 | 7 | export default Response; -------------------------------------------------------------------------------- /src/printer/macros/IMacro.ts: -------------------------------------------------------------------------------- 1 | interface IMacro { 2 | readonly name: string; 3 | execute: (params: Record) => Promise; 4 | } 5 | 6 | export { IMacro }; -------------------------------------------------------------------------------- /src/printer/jobs/IQueuedJob.ts: -------------------------------------------------------------------------------- 1 | interface IQueuedJob { 2 | filename: string; 3 | jobId: string; 4 | timeAdded: number; 5 | timeInQueue: number; 6 | } 7 | 8 | export { IQueuedJob }; -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | .idea/ 4 | *.iml 5 | 6 | .git/ 7 | .gitignore 8 | 9 | dist/ 10 | marlinraker_files/ 11 | docs/ 12 | 13 | mkdocs.yml 14 | Dockerfile 15 | cliff.toml 16 | README.md 17 | LICENSE -------------------------------------------------------------------------------- /src/api/notifications/INotification.ts: -------------------------------------------------------------------------------- 1 | interface INotification { 2 | jsonrpc: "2.0"; 3 | method: string; 4 | params?: TParams; 5 | toString: () => Promise; 6 | } 7 | 8 | export { INotification }; -------------------------------------------------------------------------------- /src/files/IFile.ts: -------------------------------------------------------------------------------- 1 | import PrintJob from "../printer/jobs/PrintJob"; 2 | 3 | interface IFile { 4 | filename: string; 5 | permissions: string; 6 | size?: number; 7 | modified?: number; 8 | getPrintJob?: () => PrintJob; 9 | } 10 | 11 | export { IFile }; -------------------------------------------------------------------------------- /docs/advanced/developing.md: -------------------------------------------------------------------------------- 1 | # Developing 2 | 3 | ## For client developers 4 | 5 | To identify a Marlinraker instance, check the ``/server/info`` API endpoint. For regular 6 | Moonraker installations ``type`` will not be defined. For Marlinraker it will always be 7 | set to ``"marlinraker"``. -------------------------------------------------------------------------------- /src/files/HashUtils.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | 3 | class HashUtils { 4 | 5 | public static hashStringMd5(data: string): string { 6 | const md5 = crypto.createHash("md5"); 7 | return md5.update(data).digest("hex"); 8 | } 9 | } 10 | 11 | export default HashUtils; -------------------------------------------------------------------------------- /src/api/response/ResultResponse.ts: -------------------------------------------------------------------------------- 1 | import Response from "./Response"; 2 | 3 | class ResultResponse extends Response { 4 | 5 | private readonly result: TResult; 6 | 7 | public constructor(result: TResult) { 8 | super(); 9 | this.result = result; 10 | } 11 | } 12 | 13 | export default ResultResponse; -------------------------------------------------------------------------------- /src/api/response/ErrorResponse.ts: -------------------------------------------------------------------------------- 1 | import Response from "./Response"; 2 | 3 | class ErrorResponse extends Response { 4 | 5 | private readonly error: { code: number; message: string }; 6 | 7 | public constructor(code: number, message: string) { 8 | super(); 9 | this.error = { code, message }; 10 | } 11 | } 12 | 13 | export default ErrorResponse; -------------------------------------------------------------------------------- /src/printer/jobs/JobQueue.ts: -------------------------------------------------------------------------------- 1 | import { IQueuedJob } from "./IQueuedJob"; 2 | 3 | type TQueueState = "ready" | "loading" | "starting" | "paused"; 4 | 5 | class JobQueue { 6 | 7 | public state: TQueueState; 8 | public queue: IQueuedJob[]; 9 | 10 | public constructor() { 11 | this.state = "ready"; 12 | this.queue = []; 13 | } 14 | } 15 | 16 | export default JobQueue; 17 | export { TQueueState }; -------------------------------------------------------------------------------- /config/marlinraker.toml: -------------------------------------------------------------------------------- 1 | # 2 | 3 | [web] 4 | port = 7125 5 | cors_domains = [] 6 | 7 | [serial] 8 | port = "auto" 9 | baud_rate = "auto" 10 | max_connection_attempts = 5 11 | connection_timeout = 5000 12 | 13 | [misc] 14 | octoprint_compat = true 15 | extended_logs = false 16 | report_velocity = false 17 | allowed_services = ["marlinraker", "crowsnest", "MoonCord", 18 | "moonraker-telegram-bot", "KlipperScreen", "sonar", "webcamd"] -------------------------------------------------------------------------------- /src/api/executors/PrinterGcodeHelpExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | 3 | type TResult = Record; 4 | 5 | class PrinterGcodeHelpExecutor implements IMethodExecutor { 6 | 7 | public readonly name = "printer.gcode.help"; 8 | 9 | public invoke(_: TSender, __: undefined): TResult { 10 | // @TODO 11 | return {}; 12 | } 13 | } 14 | 15 | export default PrinterGcodeHelpExecutor; -------------------------------------------------------------------------------- /src/files/IDirectory.ts: -------------------------------------------------------------------------------- 1 | import { IFile } from "./IFile"; 2 | 3 | interface IDirectory { 4 | dirname: string; 5 | permissions: string; 6 | size?: number; 7 | modified?: number; 8 | root: IDirectory; 9 | 10 | getSubDirs(): Promise; 11 | 12 | getSubDir(name: string): Promise; 13 | 14 | getFiles(): Promise; 15 | 16 | getFile(name: string): Promise; 17 | } 18 | 19 | export { IDirectory }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2021", 4 | "strict": true, 5 | "module": "CommonJS", 6 | "moduleResolution": "node", 7 | "allowSyntheticDefaultImports": true, 8 | "resolveJsonModule": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "outDir": "dist/", 12 | "lib": ["ES2021"], 13 | "importHelpers": true, 14 | "sourceMap": true 15 | }, 16 | "include": ["src/**/*"], 17 | "exclude": ["node_modules"], 18 | } -------------------------------------------------------------------------------- /src/api/executors/IMethodExecutor.ts: -------------------------------------------------------------------------------- 1 | import WebSocket from "ws"; 2 | import { Socket } from "net"; 3 | 4 | type TSender = WebSocket | Socket; 5 | 6 | interface IMethodExecutor { 7 | readonly name: string; 8 | readonly httpMethod?: null | "get" | "post" | "delete"; 9 | readonly httpName?: string; 10 | readonly timeout?: null | number; 11 | invoke: (sender: TSender, params: Partial) => TResult | Promise | string | null; 12 | } 13 | 14 | export { TSender, IMethodExecutor }; -------------------------------------------------------------------------------- /src/printer/macros/PauseMacro.ts: -------------------------------------------------------------------------------- 1 | import { IMacro } from "./IMacro"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | class PauseMacro implements IMacro { 5 | 6 | public readonly name = "pause"; 7 | private readonly marlinRaker: MarlinRaker; 8 | 9 | public constructor(marlinRaker: MarlinRaker) { 10 | this.marlinRaker = marlinRaker; 11 | } 12 | 13 | public async execute(_: Record): Promise { 14 | await this.marlinRaker.jobManager.pause(); 15 | } 16 | } 17 | 18 | export default PauseMacro; -------------------------------------------------------------------------------- /src/printer/macros/ResumeMacro.ts: -------------------------------------------------------------------------------- 1 | import { IMacro } from "./IMacro"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | class ResumeMacro implements IMacro { 5 | 6 | public readonly name = "resume"; 7 | private readonly marlinRaker: MarlinRaker; 8 | 9 | public constructor(marlinRaker: MarlinRaker) { 10 | this.marlinRaker = marlinRaker; 11 | } 12 | 13 | public async execute(_: Record): Promise { 14 | await this.marlinRaker.jobManager.resume(); 15 | } 16 | } 17 | 18 | export default ResumeMacro; -------------------------------------------------------------------------------- /src/printer/macros/StartPrintMacro.ts: -------------------------------------------------------------------------------- 1 | import { IMacro } from "./IMacro"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | class StartPrintMacro implements IMacro { 5 | 6 | public readonly name = "start_print"; 7 | private readonly marlinRaker: MarlinRaker; 8 | 9 | public constructor(marlinRaker: MarlinRaker) { 10 | this.marlinRaker = marlinRaker; 11 | } 12 | 13 | public async execute(_: Record): Promise { 14 | await this.marlinRaker.jobManager.start(); 15 | } 16 | } 17 | 18 | export default StartPrintMacro; -------------------------------------------------------------------------------- /src/util/NamedObjectMap.ts: -------------------------------------------------------------------------------- 1 | interface INamedObject { 2 | name: string; 3 | } 4 | 5 | class NamedObjectMap extends Map { 6 | 7 | public constructor(objects: T[] = []) { 8 | super(); 9 | objects.forEach((o) => this.set(o.name, o)); 10 | } 11 | 12 | public add(object: T): this { 13 | this.set(object.name, object); 14 | return this; 15 | } 16 | 17 | public remove(object: T): boolean { 18 | return this.delete(object.name); 19 | } 20 | } 21 | 22 | export default NamedObjectMap; -------------------------------------------------------------------------------- /src/printer/macros/CancelPrintMacro.ts: -------------------------------------------------------------------------------- 1 | import { IMacro } from "./IMacro"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | class CancelPrintMacro implements IMacro { 5 | 6 | public readonly name = "cancel_print"; 7 | private readonly marlinRaker: MarlinRaker; 8 | 9 | public constructor(marlinRaker: MarlinRaker) { 10 | this.marlinRaker = marlinRaker; 11 | } 12 | 13 | public async execute(_: Record): Promise { 14 | await this.marlinRaker.jobManager.cancel(); 15 | } 16 | } 17 | 18 | export default CancelPrintMacro; -------------------------------------------------------------------------------- /src/api/notifications/SimpleNotification.ts: -------------------------------------------------------------------------------- 1 | import { INotification } from "./INotification"; 2 | 3 | class SimpleNotification implements INotification { 4 | 5 | public readonly jsonrpc = "2.0"; 6 | public readonly method: string; 7 | public readonly params?: unknown[]; 8 | 9 | public constructor(method: string, params?: unknown[]) { 10 | this.method = method; 11 | this.params = params; 12 | } 13 | 14 | public async toString(): Promise { 15 | return JSON.stringify(this); 16 | } 17 | } 18 | 19 | export default SimpleNotification; -------------------------------------------------------------------------------- /src/printer/macros/SdcardResetFileMacro.ts: -------------------------------------------------------------------------------- 1 | import { IMacro } from "./IMacro"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | class SdcardResetFileMacro implements IMacro { 5 | 6 | public readonly name = "sdcard_reset_file"; 7 | private readonly marlinRaker: MarlinRaker; 8 | 9 | public constructor(marlinRaker: MarlinRaker) { 10 | this.marlinRaker = marlinRaker; 11 | } 12 | 13 | public async execute(_: Record): Promise { 14 | await this.marlinRaker.jobManager.reset(); 15 | } 16 | } 17 | 18 | export default SdcardResetFileMacro; -------------------------------------------------------------------------------- /src/printer/watchers/Watcher.ts: -------------------------------------------------------------------------------- 1 | abstract class Watcher { 2 | 3 | private resolveLoadingPromise!: () => void; 4 | private readonly loadingPromise: Promise; 5 | 6 | protected constructor() { 7 | this.loadingPromise = new Promise((resolve) => { 8 | this.resolveLoadingPromise = resolve; 9 | }); 10 | } 11 | 12 | public async waitForLoad(): Promise { 13 | return this.loadingPromise; 14 | } 15 | 16 | protected onLoaded(): void { 17 | this.resolveLoadingPromise(); 18 | } 19 | 20 | public abstract handle(line: string): boolean; 21 | 22 | public abstract cleanup(): void; 23 | } 24 | 25 | export default Watcher; -------------------------------------------------------------------------------- /docs/advanced/cli-arguments.md: -------------------------------------------------------------------------------- 1 | # CLI Arguments 2 | 3 | ## List of CLI arguments 4 | 5 | CLI arguments are mostly supposed for use in development. 6 | 7 | - `--find-ports`
8 | List available serial ports and exit 9 | - `--silent`
10 | Do not print log to console 11 | - `--no-log`
12 | Do not log output to files (`marlinraker_files/logs/marlinraker.txt`) 13 | - `--extended-logs`
14 | Include debugging information in console and logs (See `extended_logs` 15 | option in [Configuration](../configuration.md)) 16 | - `--serve-static`
17 | Serve `marlinraker_files/www` directory with express 18 | 19 | You can pass CLI arguments to the program like follows: 20 | 21 | `npm run start -- --silent --no-log [...]` -------------------------------------------------------------------------------- /src/api/executors/ServerRestartExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | class ServerRestartExecutor implements IMethodExecutor { 5 | 6 | public readonly name = "server.restart"; 7 | public readonly httpMethod = "post"; 8 | private readonly marlinRaker: MarlinRaker; 9 | 10 | public constructor(marlinRaker: MarlinRaker) { 11 | this.marlinRaker = marlinRaker; 12 | } 13 | 14 | public invoke(_: TSender, __: undefined): string { 15 | setTimeout(async () => await this.marlinRaker.restart()); 16 | return "ok"; 17 | } 18 | } 19 | 20 | export default ServerRestartExecutor; -------------------------------------------------------------------------------- /src/api/executors/PrinterRestartExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | class PrinterRestartExecutor implements IMethodExecutor { 5 | 6 | public readonly name = "printer.restart"; 7 | public readonly httpMethod = "post"; 8 | private readonly marlinRaker: MarlinRaker; 9 | 10 | public constructor(marlinRaker: MarlinRaker) { 11 | this.marlinRaker = marlinRaker; 12 | } 13 | 14 | public invoke(_: TSender, __: undefined): string { 15 | setTimeout(async () => await this.marlinRaker.restart()); 16 | return "ok"; 17 | } 18 | } 19 | 20 | export default PrinterRestartExecutor; -------------------------------------------------------------------------------- /src/api/executors/ServerConfigExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import { IFileInfo } from "../../config/Config"; 3 | import { config } from "../../Server"; 4 | 5 | interface TResult { 6 | config: unknown; 7 | orig: unknown; 8 | files: IFileInfo[]; 9 | } 10 | 11 | class ServerConfigExecutor implements IMethodExecutor { 12 | 13 | public readonly name = "server.config"; 14 | 15 | public invoke(_: TSender, __: undefined): TResult { 16 | return { 17 | config: config.config, 18 | orig: config.config, 19 | files: config.files 20 | }; 21 | } 22 | } 23 | 24 | export default ServerConfigExecutor; -------------------------------------------------------------------------------- /src/api/executors/MachineProcStatsExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import { IProcStats } from "../../system/ProcStats"; 3 | import MarlinRaker from "../../MarlinRaker"; 4 | 5 | class MachineProcStatsExecutor implements IMethodExecutor { 6 | 7 | public readonly name = "machine.proc_stats"; 8 | private readonly marlinRaker: MarlinRaker; 9 | 10 | public constructor(marlinRaker: MarlinRaker) { 11 | this.marlinRaker = marlinRaker; 12 | } 13 | 14 | public invoke(_: TSender, __: undefined): IProcStats | {} { 15 | return this.marlinRaker.systemInfo.procStats.getProcStats(); 16 | } 17 | } 18 | 19 | export default MachineProcStatsExecutor; -------------------------------------------------------------------------------- /src/api/executors/PrinterFirmwareRestartExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | class PrinterFirmwareRestartExecutor implements IMethodExecutor { 5 | 6 | public readonly name = "printer.firmware_restart"; 7 | public readonly httpMethod = "post"; 8 | private readonly marlinRaker: MarlinRaker; 9 | 10 | public constructor(marlinRaker: MarlinRaker) { 11 | this.marlinRaker = marlinRaker; 12 | } 13 | 14 | public invoke(_: TSender, __: undefined): string { 15 | setTimeout(async () => await this.marlinRaker.reconnect()); 16 | return "ok"; 17 | } 18 | } 19 | 20 | export default PrinterFirmwareRestartExecutor; -------------------------------------------------------------------------------- /src/api/executors/AccessOneshotTokenExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import { Socket } from "net"; 3 | import MarlinRaker from "../../MarlinRaker"; 4 | 5 | class AccessOneshotTokenExecutor implements IMethodExecutor { 6 | 7 | public readonly name = "access.oneshot_token"; 8 | private readonly marlinRaker: MarlinRaker; 9 | 10 | public constructor(marlinRaker: MarlinRaker) { 11 | this.marlinRaker = marlinRaker; 12 | } 13 | 14 | public invoke(sender: TSender, _: undefined): string | null { 15 | if (!(sender instanceof Socket)) return null; 16 | return this.marlinRaker.accessManager.generateOneshotToken(sender); 17 | } 18 | } 19 | 20 | export default AccessOneshotTokenExecutor; -------------------------------------------------------------------------------- /src/api/executors/MachineUpdateFullExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | class MachineUpdateFullExecutor implements IMethodExecutor { 5 | 6 | public readonly name = "machine.update.full"; 7 | public readonly httpMethod = "post"; 8 | public readonly timeout = null; 9 | private readonly marlinRaker: MarlinRaker; 10 | 11 | public constructor(marlinRaker: MarlinRaker) { 12 | this.marlinRaker = marlinRaker; 13 | } 14 | 15 | public async invoke(_: TSender, __: undefined): Promise { 16 | await this.marlinRaker.updateManager.fullUpdate(); 17 | return "ok"; 18 | } 19 | } 20 | 21 | export default MachineUpdateFullExecutor; -------------------------------------------------------------------------------- /src/api/executors/PrinterObjectsListExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | interface IResult { 5 | objects: string[]; 6 | } 7 | 8 | class PrinterObjectsListExecutor implements IMethodExecutor { 9 | 10 | public readonly name = "printer.objects.list"; 11 | private readonly marlinRaker: MarlinRaker; 12 | 13 | public constructor(marlinRaker: MarlinRaker) { 14 | this.marlinRaker = marlinRaker; 15 | } 16 | 17 | public invoke(_: TSender, __: undefined): IResult { 18 | return { 19 | objects: Array.from(this.marlinRaker.objectManager.objects.keys()) 20 | }; 21 | } 22 | } 23 | 24 | export default PrinterObjectsListExecutor; -------------------------------------------------------------------------------- /src/api/executors/ServerHistoryTotals.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import { IJobTotals } from "../../printer/jobs/JobHistory"; 3 | import MarlinRaker from "../../MarlinRaker"; 4 | 5 | interface IResult { 6 | job_totals: IJobTotals; 7 | } 8 | 9 | class ServerHistoryTotals implements IMethodExecutor { 10 | 11 | public readonly name = "server.history.totals"; 12 | private readonly marlinRaker: MarlinRaker; 13 | 14 | public constructor(marlinRaker: MarlinRaker) { 15 | this.marlinRaker = marlinRaker; 16 | } 17 | 18 | public invoke(_: TSender, __: undefined): IResult { 19 | return { job_totals: this.marlinRaker.jobHistory.jobTotals }; 20 | } 21 | } 22 | 23 | export default ServerHistoryTotals; -------------------------------------------------------------------------------- /src/api/executors/MachineUpdateSystemExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | class MachineUpdateSystemExecutor implements IMethodExecutor { 5 | 6 | public readonly name = "machine.update.system"; 7 | public readonly httpMethod = "post"; 8 | public readonly timeout = null; 9 | private readonly marlinRaker: MarlinRaker; 10 | 11 | public constructor(marlinRaker: MarlinRaker) { 12 | this.marlinRaker = marlinRaker; 13 | } 14 | 15 | public async invoke(_: TSender, __: undefined): Promise { 16 | await this.marlinRaker.updateManager.update("system"); 17 | return "ok"; 18 | } 19 | } 20 | 21 | export default MachineUpdateSystemExecutor; -------------------------------------------------------------------------------- /src/api/executors/ServerDatabaseListExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | interface IResult { 5 | namespaces: string[]; 6 | } 7 | 8 | class ServerDatabaseListExecutor implements IMethodExecutor { 9 | 10 | public readonly name = "server.database.list"; 11 | private readonly marlinRaker: MarlinRaker; 12 | 13 | public constructor(marlinRaker: MarlinRaker) { 14 | this.marlinRaker = marlinRaker; 15 | } 16 | 17 | public async invoke(_: TSender, __: undefined): Promise { 18 | return { 19 | namespaces: await this.marlinRaker.database.getNamespaces() 20 | }; 21 | } 22 | } 23 | 24 | export default ServerDatabaseListExecutor; -------------------------------------------------------------------------------- /src/printer/objects/PauseResumeObject.ts: -------------------------------------------------------------------------------- 1 | import PrinterObject from "./PrinterObject"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | interface IObject { 5 | is_paused: boolean; 6 | } 7 | 8 | class PauseResumeObject extends PrinterObject { 9 | 10 | public readonly name = "pause_resume"; 11 | private readonly marlinRaker: MarlinRaker; 12 | 13 | public constructor(marlinRaker: MarlinRaker) { 14 | super(); 15 | this.marlinRaker = marlinRaker; 16 | 17 | this.marlinRaker.jobManager.on("stateChange", this.emit.bind(this)); 18 | } 19 | 20 | protected get(): IObject { 21 | return { 22 | is_paused: this.marlinRaker.jobManager.state === "paused" 23 | }; 24 | } 25 | } 26 | 27 | export default PauseResumeObject; -------------------------------------------------------------------------------- /src/api/executors/PrinterEmergencyStopExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | class PrinterEmergencyStopExecutor implements IMethodExecutor { 5 | 6 | public readonly name = "printer.emergency_stop"; 7 | public readonly httpMethod = "post"; 8 | private readonly marlinRaker: MarlinRaker; 9 | 10 | public constructor(marlinRaker: MarlinRaker) { 11 | this.marlinRaker = marlinRaker; 12 | } 13 | 14 | public invoke(_: TSender, __: undefined): string { 15 | if (this.marlinRaker.state !== "ready") throw new Error("Printer not ready"); 16 | this.marlinRaker.printer?.emergencyStop(); 17 | return "ok"; 18 | } 19 | } 20 | 21 | export default PrinterEmergencyStopExecutor; -------------------------------------------------------------------------------- /src/api/executors/ServerFilesListExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import { IFileInfo } from "../../files/FileManager"; 3 | import MarlinRaker from "../../MarlinRaker"; 4 | 5 | interface IParams { 6 | root: "gcodes" | "config"; 7 | } 8 | 9 | class ServerFilesListExecutor implements IMethodExecutor { 10 | 11 | public readonly name = "server.files.list"; 12 | private readonly marlinRaker: MarlinRaker; 13 | 14 | public constructor(marlinRaker: MarlinRaker) { 15 | this.marlinRaker = marlinRaker; 16 | } 17 | 18 | public async invoke(_: TSender, params: Partial): Promise { 19 | return await this.marlinRaker.fileManager.listFiles(params.root ?? "gcodes"); 20 | } 21 | } 22 | 23 | export default ServerFilesListExecutor; -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Marlinraker Documentation 2 | site_url: https://marlinraker.readthedocs.io 3 | repo_url: https://github.com/pauhull/marlinraker 4 | markdown_extensions: 5 | - attr_list 6 | - md_in_html 7 | - admonition 8 | - pymdownx.highlight: 9 | anchor_linenums: true 10 | - pymdownx.inlinehilite 11 | - pymdownx.snippets 12 | - pymdownx.superfences 13 | nav: 14 | - index.md 15 | - installation.md 16 | - configuration.md 17 | - security.md 18 | - Troubleshooting: 19 | - troubleshooting/stuttering-and-blobs.md 20 | - Advanced: 21 | - advanced/macro-scripting.md 22 | - advanced/update-manager.md 23 | - advanced/cli-arguments.md 24 | - advanced/developing.md 25 | theme: 26 | name: material 27 | palette: 28 | scheme: slate 29 | primary: green 30 | icon: 31 | repo: fontawesome/brands/github -------------------------------------------------------------------------------- /src/printer/objects/WebhooksObject.ts: -------------------------------------------------------------------------------- 1 | import PrinterObject from "./PrinterObject"; 2 | import MarlinRaker, { TPrinterState } from "../../MarlinRaker"; 3 | 4 | interface IObject { 5 | state: TPrinterState; 6 | state_message?: string; 7 | } 8 | 9 | class WebhooksObject extends PrinterObject { 10 | 11 | public readonly name = "webhooks"; 12 | private readonly marlinRaker: MarlinRaker; 13 | 14 | public constructor(marlinRaker: MarlinRaker) { 15 | super(); 16 | this.marlinRaker = marlinRaker; 17 | this.marlinRaker.on("stateChange", this.emit.bind(this)); 18 | } 19 | 20 | protected get(): IObject { 21 | return { 22 | state: this.marlinRaker.state, 23 | state_message: this.marlinRaker.stateMessage 24 | }; 25 | } 26 | } 27 | 28 | export default WebhooksObject; -------------------------------------------------------------------------------- /src/auth/AccessManager.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from "net"; 2 | import { base32 } from "rfc4648"; 3 | import * as crypto from "crypto"; 4 | 5 | class AccessManager { 6 | 7 | private readonly oneshotTokens: { client: Socket; time: number; token: string }[]; 8 | 9 | public constructor() { 10 | this.oneshotTokens = []; 11 | } 12 | 13 | public generateOneshotToken(socket: Socket): string { 14 | let token = ""; 15 | do { 16 | token = base32.stringify(crypto.webcrypto.getRandomValues(new Uint8Array(20)), { pad: false }); 17 | } while (this.oneshotTokens.some((t) => t.token === token)); 18 | this.oneshotTokens.push({ 19 | client: socket, 20 | time: Date.now(), 21 | token 22 | }); 23 | return token; 24 | } 25 | } 26 | 27 | export default AccessManager; -------------------------------------------------------------------------------- /src/api/executors/PrinterQueryEndstopsStatusExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | type TResult = Record; 5 | 6 | class PrinterQueryEndstopsStatusExecutor implements IMethodExecutor { 7 | 8 | public readonly name = "printer.query_endstops.status"; 9 | private readonly marlinRaker: MarlinRaker; 10 | 11 | public constructor(marlinRaker: MarlinRaker) { 12 | this.marlinRaker = marlinRaker; 13 | } 14 | 15 | public async invoke(_: TSender, __: Partial): Promise { 16 | if (this.marlinRaker.state !== "ready") throw new Error("Printer not ready"); 17 | return await this.marlinRaker.printer?.queryEndstops() ?? {}; 18 | } 19 | } 20 | 21 | export default PrinterQueryEndstopsStatusExecutor; -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Copy source files and build distributable 2 | FROM node:16-alpine AS build 3 | RUN apk add --update python3 make g++ 4 | 5 | WORKDIR /marlinraker_build 6 | COPY ./ /marlinraker_build 7 | RUN npm install && npm run build 8 | 9 | # Copy build output and install dependencies 10 | FROM node:16-alpine AS prepare 11 | RUN apk add --update python3 make gcc g++ linux-headers udev 12 | 13 | WORKDIR /marlinraker 14 | COPY --from=build /marlinraker_build/dist . 15 | RUN npm install --unsafe-perm --build-from-source 16 | 17 | # Copy files, create user and start program 18 | FROM node:16-alpine 19 | 20 | RUN apk add --update sudo eudev 21 | RUN adduser -h /marlinraker -G dialout -u 1001 -D marlinraker 22 | 23 | WORKDIR /marlinraker 24 | USER marlinraker 25 | COPY --from=prepare /marlinraker . 26 | 27 | EXPOSE 7125 28 | VOLUME ["/marlinraker_files"] 29 | CMD ["npm", "run", "start"] -------------------------------------------------------------------------------- /src/api/executors/PrinterPrintPauseExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | class PrinterPrintPauseExecutor implements IMethodExecutor { 5 | 6 | public readonly name = "printer.print.pause"; 7 | public readonly httpMethod = "post"; 8 | public readonly timeout = null; 9 | private readonly marlinRaker: MarlinRaker; 10 | 11 | public constructor(marlinRaker: MarlinRaker) { 12 | this.marlinRaker = marlinRaker; 13 | } 14 | 15 | public async invoke(_: TSender, __: undefined): Promise { 16 | if (this.marlinRaker.state !== "ready") throw new Error("Printer not ready"); 17 | await this.marlinRaker.dispatchCommand("pause", false); 18 | return "ok"; 19 | } 20 | } 21 | 22 | export default PrinterPrintPauseExecutor; -------------------------------------------------------------------------------- /src/api/executors/PrinterPrintResumeExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | class PrinterPrintResumeExecutor implements IMethodExecutor { 5 | 6 | public readonly name = "printer.print.resume"; 7 | public readonly httpMethod = "post"; 8 | public readonly timeout = null; 9 | private readonly marlinRaker: MarlinRaker; 10 | 11 | public constructor(marlinRaker: MarlinRaker) { 12 | this.marlinRaker = marlinRaker; 13 | } 14 | 15 | public async invoke(_: TSender, __: undefined): Promise { 16 | if (this.marlinRaker.state !== "ready") throw new Error("Printer not ready"); 17 | await this.marlinRaker.dispatchCommand("resume", false); 18 | return "ok"; 19 | } 20 | } 21 | 22 | export default PrinterPrintResumeExecutor; -------------------------------------------------------------------------------- /src/api/executors/PrinterPrintCancelExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | class PrinterPrintCancelExecutor implements IMethodExecutor { 5 | 6 | public readonly name = "printer.print.cancel"; 7 | public readonly httpMethod = "post"; 8 | public readonly timeout = null; 9 | private readonly marlinRaker: MarlinRaker; 10 | 11 | public constructor(marlinRaker: MarlinRaker) { 12 | this.marlinRaker = marlinRaker; 13 | } 14 | 15 | public async invoke(_: TSender, __: undefined): Promise { 16 | if (this.marlinRaker.state !== "ready") throw new Error("Printer not ready"); 17 | await this.marlinRaker.dispatchCommand("cancel_print", false); 18 | return "ok"; 19 | } 20 | } 21 | 22 | export default PrinterPrintCancelExecutor; -------------------------------------------------------------------------------- /src/api/executors/ServerGcodeStoreExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker, { IGcodeLog } from "../../MarlinRaker"; 3 | 4 | interface IParams { 5 | count: number; 6 | } 7 | 8 | interface IResult { 9 | gcode_store: IGcodeLog[]; 10 | } 11 | 12 | class ServerGcodeStoreExecutor implements IMethodExecutor { 13 | 14 | public readonly name = "server.gcode_store"; 15 | private readonly marlinRaker: MarlinRaker; 16 | 17 | public constructor(marlinRaker: MarlinRaker) { 18 | this.marlinRaker = marlinRaker; 19 | } 20 | 21 | public invoke(_: TSender, params: Partial): IResult { 22 | return { 23 | gcode_store: this.marlinRaker.gcodeStore.slice(-Math.min(params.count ?? Infinity, 1000)) 24 | }; 25 | } 26 | } 27 | 28 | export default ServerGcodeStoreExecutor; -------------------------------------------------------------------------------- /src/api/executors/MachineRebootExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import { exec } from "child_process"; 3 | 4 | class MachineRebootExecutor implements IMethodExecutor { 5 | 6 | public readonly name = "machine.reboot"; 7 | public readonly httpMethod = "post"; 8 | 9 | public invoke(_: TSender, __: Partial): string { 10 | if (process.env.NODE_ENV !== "production") throw new Error("Cannot reboot in dev mode"); 11 | if (process.platform === "linux" || process.platform === "darwin") { 12 | exec("sudo shutdown -r now"); 13 | } else if (process.platform === "win32") { 14 | exec("shutdown /r"); 15 | } else { 16 | throw new Error(`Unsupported platform ${process.platform}`); 17 | } 18 | return "ok"; 19 | } 20 | } 21 | 22 | export default MachineRebootExecutor; -------------------------------------------------------------------------------- /src/api/executors/ServerTemperatureStoreExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | import { ITempRecord } from "../../printer/HeaterManager"; 4 | 5 | type TResult = Record; 6 | 7 | class ServerTemperatureStoreExecutor implements IMethodExecutor { 8 | 9 | public readonly name = "server.temperature_store"; 10 | private readonly marlinRaker: MarlinRaker; 11 | 12 | public constructor(marlinRaker: MarlinRaker) { 13 | this.marlinRaker = marlinRaker; 14 | } 15 | 16 | public invoke(_: TSender, __: undefined): TResult { 17 | if (this.marlinRaker.state !== "ready") throw new Error("Printer not ready"); 18 | return Object.fromEntries(this.marlinRaker.printer?.heaterManager.records ?? []); 19 | } 20 | } 21 | 22 | export default ServerTemperatureStoreExecutor; -------------------------------------------------------------------------------- /src/api/executors/MachineUpdateClientExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | interface IParams { 5 | name: string; 6 | } 7 | 8 | class MachineUpdateClientExecutor implements IMethodExecutor { 9 | 10 | public readonly name = "machine.update.client"; 11 | public readonly httpMethod = "post"; 12 | public readonly timeout = null; 13 | private readonly marlinRaker: MarlinRaker; 14 | 15 | public constructor(marlinRaker: MarlinRaker) { 16 | this.marlinRaker = marlinRaker; 17 | } 18 | 19 | public async invoke(_: TSender, params: Partial): Promise { 20 | if (!params.name) throw new Error("Invalid name"); 21 | await this.marlinRaker.updateManager.update(params.name); 22 | return "ok"; 23 | } 24 | } 25 | 26 | export default MachineUpdateClientExecutor; -------------------------------------------------------------------------------- /docs/security.md: -------------------------------------------------------------------------------- 1 | # Security 2 | 3 | ## Important security notice 4 | - **Never** expose Marlinraker outside your local network! 5 | - **Never** use Marlinraker in an untrusted public network! 6 | - **Do not** leave your printer unattended when printing! 7 | 8 | Marlinraker makes your printer fully controllable over the network 9 | it is connected to. A malicious person in your network could send 10 | G-codes to your printer that could potentially damage it or even 11 | **cause a fire**! Although modern printer firmwares have failsafes 12 | in place, it is never a good idea to make your printer publicly 13 | accessible. [Here's why](https://isc.sans.edu/forums/diary/3D+Printers+in+The+Wild+What+Can+Go+Wrong/24044/). 14 | 15 | If you want to remotely **and** securely connect to your 3D printer, 16 | consider setting up a private VPN. Popular home VPN options include 17 | [PiVPN](https://www.pivpn.io/) and [OpenVPN](https://openvpn.net/). -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](https://img.shields.io/github/v/tag/pauhull/marlinraker?label=release) 2 | ![](https://img.shields.io/github/last-commit/pauhull/marlinraker) 3 | ![](https://img.shields.io/github/license/pauhull/marlinraker) 4 | ![](https://img.shields.io/github/issues-raw/pauhull/marlinraker) 5 | ![](https://img.shields.io/tokei/lines/github/pauhull/marlinraker) 6 | ![](https://img.shields.io/github/downloads/pauhull/marlinraker/total) 7 | 8 | ## Overview 9 | Marlinraker is a tool that connects an external device to your Marlin 3D Printer 10 | via serial and emulates Moonraker API endpoints. This enables you to use 11 | your favorite Klipper interfaces like Mainsail and Fluidd with Marlin 12 | machines. The Node.js runtime makes it performant enough to easily run 13 | on low-power SBCs like the Raspberry Pi Zero W and even on Windows machines. 14 | 15 | For more information, check out the [official documentation](https://marlinraker.readthedocs.io/). -------------------------------------------------------------------------------- /src/api/executors/ServerHistoryResetTotals.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import { IJobTotals } from "../../printer/jobs/JobHistory"; 3 | import MarlinRaker from "../../MarlinRaker"; 4 | 5 | interface IResult { 6 | last_totals: IJobTotals; 7 | } 8 | 9 | class ServerHistoryResetTotals implements IMethodExecutor { 10 | 11 | public readonly name = "server.history.reset_totals"; 12 | public readonly httpMethod = "post"; 13 | private readonly marlinRaker: MarlinRaker; 14 | 15 | public constructor(marlinRaker: MarlinRaker) { 16 | this.marlinRaker = marlinRaker; 17 | } 18 | 19 | public invoke(_: TSender, __: undefined): IResult { 20 | const totals = this.marlinRaker.jobHistory.jobTotals; 21 | this.marlinRaker.jobHistory.resetTotals(); 22 | return { last_totals: totals }; 23 | } 24 | } 25 | 26 | export default ServerHistoryResetTotals; -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to Marlinraker will be documented in this file. 4 | 5 | ## 0.2.3-alpha - 2023-04-10 6 | 7 | ### Bug Fixes 8 | 9 | - Add misc.allowed_services to config/marlinraker.toml and docs ([b3d4abd](b3d4abdcbaf019286a0ca141db06729c7b3befbb)) 10 | - Don't crash when udevadm isn't found ([223d6e9](223d6e97eee9fce9779955f2381ce6c4660ff3b2)) 11 | - Fix Docker image ([9db7b09](9db7b0948c82812d0eeba57569f5039444f401fd)) 12 | - Fix "Printer initialization took too long" on Prusa printers ([b76f0c2](b76f0c2241e181cfb6712ac1d5af858ca8e02e54)) 13 | - Show velocity while printing when "report_velocity" is enabled ([70a4866](70a4866f3404ae4c37fe98474dc8226d9f17fb6e)) 14 | 15 | ### Features 16 | 17 | - Add and document 'cors_domains' configuration option ([b2965e3](b2965e3359a4485c071d49b3a6f7a61f2977f025)) 18 | - Add printer.extruder.filament_diameter property ([cda5adb](cda5adbd689228fbcb23651bcd85e50f4c6c342c)) 19 | 20 | -------------------------------------------------------------------------------- /src/api/executors/ServerFilesMetadataExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import { IGcodeMetadata } from "../../files/MetadataManager"; 3 | import MarlinRaker from "../../MarlinRaker"; 4 | 5 | interface IParams { 6 | filename: string; 7 | } 8 | 9 | class ServerFilesMetadataExecutor implements IMethodExecutor { 10 | 11 | public readonly name = "server.files.metadata"; 12 | private readonly marlinRaker: MarlinRaker; 13 | 14 | public constructor(marlinRaker: MarlinRaker) { 15 | this.marlinRaker = marlinRaker; 16 | } 17 | 18 | public async invoke(_: TSender, params: Partial): Promise { 19 | if (!params.filename) throw new Error("Invalid filename"); 20 | return await this.marlinRaker.metadataManager.getOrGenerateMetadata(params.filename); 21 | } 22 | } 23 | 24 | export default ServerFilesMetadataExecutor; -------------------------------------------------------------------------------- /src/api/executors/PrinterGcodeScriptExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | interface IParams { 5 | script: string; 6 | } 7 | 8 | class PrinterGcodeScriptExecutor implements IMethodExecutor { 9 | 10 | public readonly name = "printer.gcode.script"; 11 | public readonly httpMethod = "post"; 12 | public readonly timeout = null; 13 | private readonly marlinRaker: MarlinRaker; 14 | 15 | public constructor(marlinRaker: MarlinRaker) { 16 | this.marlinRaker = marlinRaker; 17 | } 18 | 19 | public async invoke(_: TSender, params: Partial): Promise { 20 | if (this.marlinRaker.state !== "ready") throw new Error("Printer not ready"); 21 | await this.marlinRaker.dispatchCommand(params.script ?? ""); 22 | return "ok"; 23 | } 24 | } 25 | 26 | export default PrinterGcodeScriptExecutor; -------------------------------------------------------------------------------- /src/api/executors/MachineShutdownExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import { exec } from "child_process"; 3 | 4 | class MachineShutdownExecutor implements IMethodExecutor { 5 | 6 | public readonly name = "machine.shutdown"; 7 | public readonly httpMethod = "post"; 8 | 9 | public invoke(_: TSender, __: Partial): string { 10 | if (process.env.NODE_ENV !== "production") throw new Error("Cannot shutdown in dev mode"); 11 | if (process.platform === "linux" || process.platform === "darwin") { 12 | setTimeout(() => exec("sudo shutdown -h now")); 13 | } else if (process.platform === "win32") { 14 | setTimeout(() => exec("shutdown /s")); 15 | } else { 16 | throw new Error(`Unsupported platform ${process.platform}`); 17 | } 18 | return "ok"; 19 | } 20 | } 21 | 22 | export default MachineShutdownExecutor; -------------------------------------------------------------------------------- /src/api/executors/ServerAnnouncementsListExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | 3 | interface IParams { 4 | include_dismissed: boolean; 5 | } 6 | 7 | interface IResult { 8 | entries: { 9 | entry_id: string; 10 | url: string; 11 | title: string; 12 | description: string; 13 | priority: "normal" | "high"; 14 | date: number; 15 | dismissed: boolean; 16 | source: string; 17 | feed: string; 18 | }[]; 19 | feeds: string[]; 20 | } 21 | 22 | class ServerAnnouncementsListExecutor implements IMethodExecutor { 23 | 24 | public readonly name = "server.announcements.list"; 25 | 26 | public invoke(_: TSender, __: Partial): IResult { 27 | // @TODO 28 | return { 29 | entries: [], 30 | feeds: [] 31 | }; 32 | } 33 | } 34 | 35 | export default ServerAnnouncementsListExecutor; -------------------------------------------------------------------------------- /src/api/executors/ServerFilesDeleteFileExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import { IFileChangeNotification } from "../../files/FileManager"; 3 | import MarlinRaker from "../../MarlinRaker"; 4 | 5 | interface IParams { 6 | path: string; 7 | } 8 | 9 | class ServerFilesDeleteFileExecutor implements IMethodExecutor { 10 | 11 | public readonly name = "server.files.delete_file"; 12 | public readonly httpMethod = null; 13 | private readonly marlinRaker: MarlinRaker; 14 | 15 | public constructor(marlinRaker: MarlinRaker) { 16 | this.marlinRaker = marlinRaker; 17 | } 18 | 19 | public async invoke(_: TSender, params: Partial): Promise { 20 | if (!params.path) throw new Error("Invalid path"); 21 | return await this.marlinRaker.fileManager.deleteFile(params.path); 22 | } 23 | } 24 | 25 | export default ServerFilesDeleteFileExecutor; -------------------------------------------------------------------------------- /src/api/executors/MachineUpdateStatusExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import { IUpdateStatus } from "../../update/UpdateManager"; 3 | import MarlinRaker from "../../MarlinRaker"; 4 | 5 | interface IParams { 6 | refresh: boolean; 7 | } 8 | 9 | class MachineUpdateStatusExecutor implements IMethodExecutor { 10 | 11 | public readonly name = "machine.update.status"; 12 | public readonly timeout = null; 13 | private readonly marlinRaker: MarlinRaker; 14 | 15 | public constructor(marlinRaker: MarlinRaker) { 16 | this.marlinRaker = marlinRaker; 17 | } 18 | 19 | public async invoke(_: TSender, params: Partial): Promise { 20 | if (params.refresh) { 21 | await this.marlinRaker.updateManager.checkForUpdates(); 22 | } 23 | return this.marlinRaker.updateManager.getUpdateStatus(); 24 | } 25 | } 26 | 27 | export default MachineUpdateStatusExecutor; -------------------------------------------------------------------------------- /src/printer/objects/TemperatureObject.ts: -------------------------------------------------------------------------------- 1 | import PrinterObject from "./PrinterObject"; 2 | 3 | interface IObject { 4 | temperature: number; 5 | target?: number; 6 | power?: number; 7 | measured_min_temp?: number; 8 | measured_max_temp?: number; 9 | } 10 | 11 | class TemperatureObject extends PrinterObject { 12 | 13 | public readonly name: string; 14 | public temp: number; 15 | public target?: number; 16 | public power?: number; 17 | public minTemp?: number; 18 | public maxTemp?: number; 19 | 20 | public constructor(name: string) { 21 | super(); 22 | this.temp = 0; 23 | this.name = name; 24 | } 25 | 26 | protected get(): IObject { 27 | return { 28 | temperature: this.temp, 29 | power: this.power, 30 | target: this.target, 31 | measured_min_temp: this.minTemp, 32 | measured_max_temp: this.maxTemp 33 | }; 34 | } 35 | } 36 | 37 | export default TemperatureObject; -------------------------------------------------------------------------------- /src/printer/objects/FanObject.ts: -------------------------------------------------------------------------------- 1 | import PrinterObject from "./PrinterObject"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | interface IObject { 5 | speed: number; 6 | rpm?: number; // @TODO M123? 7 | } 8 | 9 | class FanObject extends PrinterObject { 10 | 11 | public readonly name = "fan"; 12 | private readonly marlinRaker: MarlinRaker; 13 | 14 | public constructor(marlinRaker: MarlinRaker) { 15 | super(); 16 | this.marlinRaker = marlinRaker; 17 | 18 | this.marlinRaker.on("stateChange", (state) => { 19 | if (state === "ready") { 20 | this.marlinRaker.printer?.on("fanSpeedChange", this.emit.bind(this)); 21 | } 22 | }); 23 | } 24 | 25 | protected get(): IObject { 26 | return { 27 | speed: this.marlinRaker.printer?.fanSpeed ?? 0 28 | }; 29 | } 30 | 31 | public isAvailable(): boolean { 32 | return this.marlinRaker.state === "ready"; 33 | } 34 | } 35 | 36 | export default FanObject; -------------------------------------------------------------------------------- /src/api/executors/MachineServicesRestartExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | interface IParams { 5 | service: string; 6 | } 7 | 8 | class MachineServicesRestartExecutor implements IMethodExecutor { 9 | 10 | public readonly name = "machine.services.restart"; 11 | public readonly httpMethod = "post"; 12 | private readonly marlinRaker: MarlinRaker; 13 | 14 | public constructor(marlinRaker: MarlinRaker) { 15 | this.marlinRaker = marlinRaker; 16 | } 17 | 18 | public invoke(_: TSender, params: Partial): string { 19 | if (!params.service || !this.marlinRaker.systemInfo.serviceManager.activeServiceList.includes(params.service)) { 20 | throw new Error("Service not active"); 21 | } 22 | setTimeout(() => void this.marlinRaker.systemInfo.serviceManager.systemctl("restart", params.service!)); 23 | return "ok"; 24 | } 25 | } 26 | 27 | export default MachineServicesRestartExecutor; -------------------------------------------------------------------------------- /src/api/executors/ServerFilesPostDirectoryExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import { IFileChangeNotification } from "../../files/FileManager"; 3 | import MarlinRaker from "../../MarlinRaker"; 4 | 5 | interface IParams { 6 | path: string; 7 | } 8 | 9 | class ServerFilesPostDirectoryExecutor implements IMethodExecutor { 10 | 11 | public readonly name = "server.files.post_directory"; 12 | public readonly httpMethod = "post"; 13 | public readonly httpName = "server.files.directory"; 14 | private readonly marlinRaker: MarlinRaker; 15 | 16 | public constructor(marlinRaker: MarlinRaker) { 17 | this.marlinRaker = marlinRaker; 18 | } 19 | 20 | public async invoke(_: TSender, params: Partial): Promise { 21 | if (!params.path) throw new Error("Invalid path"); 22 | return await this.marlinRaker.fileManager.createDirectory(params.path); 23 | } 24 | } 25 | 26 | export default ServerFilesPostDirectoryExecutor; -------------------------------------------------------------------------------- /src/api/executors/ServerHistoryDeleteJobExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | interface IParams { 5 | uid: string; 6 | all: boolean; 7 | } 8 | 9 | type TResult = string[]; 10 | 11 | class ServerHistoryDeleteJobExecutor implements IMethodExecutor { 12 | 13 | public readonly name = "server.history.delete_job"; 14 | public readonly httpName = "server.history.job"; 15 | public readonly httpMethod = "delete"; 16 | private readonly marlinRaker: MarlinRaker; 17 | 18 | public constructor(marlinRaker: MarlinRaker) { 19 | this.marlinRaker = marlinRaker; 20 | } 21 | 22 | public async invoke(_: TSender, params: Partial): Promise { 23 | if (!params.uid && !params.all) throw new Error("No uid specified and not deleting all"); 24 | return await this.marlinRaker.jobHistory.deleteJobs(params.uid ?? "", params.all ?? false); 25 | } 26 | } 27 | 28 | export default ServerHistoryDeleteJobExecutor; -------------------------------------------------------------------------------- /src/api/executors/ServerFilesCopyExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import { IFileChangeNotification } from "../../files/FileManager"; 3 | import MarlinRaker from "../../MarlinRaker"; 4 | 5 | interface IParams { 6 | source: string; 7 | dest: string; 8 | } 9 | 10 | class ServerFilesCopyExecutor implements IMethodExecutor { 11 | 12 | public readonly name = "server.files.copy"; 13 | public readonly httpMethod = "post"; 14 | private readonly marlinRaker: MarlinRaker; 15 | 16 | public constructor(marlinRaker: MarlinRaker) { 17 | this.marlinRaker = marlinRaker; 18 | } 19 | 20 | public async invoke(_: TSender, params: Partial): Promise { 21 | if (!params.source) throw new Error("Invalid source"); 22 | if (!params.dest) throw new Error("Invalid destination"); 23 | return await this.marlinRaker.fileManager.moveOrCopy(params.source, params.dest, true); 24 | } 25 | } 26 | 27 | export default ServerFilesCopyExecutor; -------------------------------------------------------------------------------- /src/api/executors/ServerFilesMoveExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import { IFileChangeNotification } from "../../files/FileManager"; 3 | import MarlinRaker from "../../MarlinRaker"; 4 | 5 | interface IParams { 6 | source: string; 7 | dest: string; 8 | } 9 | 10 | class ServerFilesMoveExecutor implements IMethodExecutor { 11 | 12 | public readonly name = "server.files.move"; 13 | public readonly httpMethod = "post"; 14 | private readonly marlinRaker: MarlinRaker; 15 | 16 | public constructor(marlinRaker: MarlinRaker) { 17 | this.marlinRaker = marlinRaker; 18 | } 19 | 20 | public async invoke(_: TSender, params: Partial): Promise { 21 | if (!params.source) throw new Error("Invalid source"); 22 | if (!params.dest) throw new Error("Invalid destination"); 23 | return await this.marlinRaker.fileManager.moveOrCopy(params.source, params.dest, false); 24 | } 25 | } 26 | 27 | export default ServerFilesMoveExecutor; -------------------------------------------------------------------------------- /src/printer/objects/SystemStatsObject.ts: -------------------------------------------------------------------------------- 1 | import PrinterObject from "./PrinterObject"; 2 | import os from "os"; 3 | import { procfs } from "@stroncium/procfs"; 4 | 5 | interface IObject { 6 | sysload: number; 7 | cputime: number; 8 | memavail: number; 9 | } 10 | 11 | class SystemStatsObject extends PrinterObject { 12 | 13 | public readonly name = "system_stats"; 14 | 15 | public constructor() { 16 | super(); 17 | setInterval(this.emit.bind(this), 1000); 18 | } 19 | 20 | protected get(): IObject { 21 | const cpuUsage = process.cpuUsage(); 22 | return { 23 | sysload: os.loadavg()[0], 24 | cputime: (cpuUsage.system + cpuUsage.system) / 1e6, 25 | memavail: SystemStatsObject.getMemAvail() / 1000 26 | }; 27 | } 28 | 29 | private static getMemAvail(): number { 30 | try { 31 | return procfs.meminfo().available; 32 | } catch (e) { 33 | return os.freemem(); 34 | } 35 | } 36 | } 37 | 38 | export default SystemStatsObject; -------------------------------------------------------------------------------- /src/api/executors/PrinterInfoExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import os from "os"; 3 | import MarlinRaker, { TPrinterState } from "../../MarlinRaker"; 4 | 5 | interface IResult { 6 | state: TPrinterState; 7 | state_message?: string; 8 | hostname: string; 9 | software_version: string; 10 | cpu_info: string; 11 | } 12 | 13 | class PrinterInfoExecutor implements IMethodExecutor { 14 | 15 | public readonly name = "printer.info"; 16 | private readonly marlinRaker: MarlinRaker; 17 | 18 | public constructor(marlinRaker: MarlinRaker) { 19 | this.marlinRaker = marlinRaker; 20 | } 21 | 22 | public async invoke(_: TSender, __: undefined): Promise { 23 | return { 24 | state: this.marlinRaker.state, 25 | state_message: this.marlinRaker.stateMessage, 26 | hostname: os.hostname(), 27 | software_version: "1.0", 28 | cpu_info: "" 29 | }; 30 | } 31 | } 32 | 33 | export default PrinterInfoExecutor; 34 | -------------------------------------------------------------------------------- /src/api/executors/ServerHistoryGetJobExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import { IHistoryJob } from "../../printer/jobs/JobHistory"; 3 | import MarlinRaker from "../../MarlinRaker"; 4 | 5 | interface IParams { 6 | uid: string; 7 | } 8 | 9 | interface IResult { 10 | job: IHistoryJob; 11 | } 12 | 13 | class ServerHistoryGetJobExecutor implements IMethodExecutor { 14 | 15 | public readonly name = "server.history.get_job"; 16 | public readonly httpName = "server.history.job"; 17 | private readonly marlinRaker: MarlinRaker; 18 | 19 | public constructor(marlinRaker: MarlinRaker) { 20 | this.marlinRaker = marlinRaker; 21 | } 22 | 23 | public invoke(_: TSender, params: Partial): IResult { 24 | if (!params.uid) throw new Error("No uid specified"); 25 | const job = this.marlinRaker.jobHistory.getJobFromId(params.uid); 26 | if (!job) throw new Error("Job doesn't exist"); 27 | return { job }; 28 | } 29 | } 30 | 31 | export default ServerHistoryGetJobExecutor; -------------------------------------------------------------------------------- /src/api/executors/ServerFilesDeleteDirectoryExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import { IFileChangeNotification } from "../../files/FileManager"; 3 | import MarlinRaker from "../../MarlinRaker"; 4 | 5 | interface IParams { 6 | path: string; 7 | force: boolean; 8 | } 9 | 10 | class ServerFilesDeleteDirectoryExecutor implements IMethodExecutor { 11 | 12 | public readonly name = "server.files.delete_directory"; 13 | public readonly httpMethod = "delete"; 14 | public readonly httpName = "server.files.directory"; 15 | private readonly marlinRaker: MarlinRaker; 16 | 17 | public constructor(marlinRaker: MarlinRaker) { 18 | this.marlinRaker = marlinRaker; 19 | } 20 | 21 | public async invoke(_: TSender, params: Partial): Promise { 22 | if (!params.path) throw new Error("Invalid path"); 23 | return await this.marlinRaker.fileManager.deleteDirectory(params.path, params.force ?? false); 24 | } 25 | } 26 | 27 | export default ServerFilesDeleteDirectoryExecutor; -------------------------------------------------------------------------------- /src/api/executors/MachineServicesStopExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | interface IParams { 5 | service: string; 6 | } 7 | 8 | class MachineServicesStopExecutor implements IMethodExecutor { 9 | 10 | public readonly name = "machine.services.stop"; 11 | public readonly httpMethod = "post"; 12 | private readonly marlinRaker: MarlinRaker; 13 | 14 | public constructor(marlinRaker: MarlinRaker) { 15 | this.marlinRaker = marlinRaker; 16 | } 17 | 18 | public invoke(_: TSender, params: Partial): string { 19 | if (!params.service || !this.marlinRaker.systemInfo.serviceManager.activeServiceList.includes(params.service)) { 20 | throw new Error("Service not active"); 21 | } 22 | if (params.service === "marlinraker") throw new Error("Cannot stop Marlinraker service"); 23 | setTimeout(() => void this.marlinRaker.systemInfo.serviceManager.systemctl("stop", params.service!)); 24 | return "ok"; 25 | } 26 | } 27 | 28 | export default MachineServicesStopExecutor; -------------------------------------------------------------------------------- /src/api/executors/MachineServicesStartExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | interface IParams { 5 | service: string; 6 | } 7 | 8 | class MachineServicesStartExecutor implements IMethodExecutor { 9 | 10 | public readonly name = "machine.services.start"; 11 | public readonly httpMethod = "post"; 12 | private readonly marlinRaker: MarlinRaker; 13 | 14 | public constructor(marlinRaker: MarlinRaker) { 15 | this.marlinRaker = marlinRaker; 16 | } 17 | 18 | public invoke(_: TSender, params: Partial): string { 19 | if (!params.service || !this.marlinRaker.systemInfo.serviceManager.activeServiceList.includes(params.service)) { 20 | throw new Error("Service not active"); 21 | } 22 | if (params.service === "marlinraker") throw new Error("Cannot start Marlinraker service"); 23 | setTimeout(() => void this.marlinRaker.systemInfo.serviceManager.systemctl("start", params.service!)); 24 | return "ok"; 25 | } 26 | } 27 | 28 | export default MachineServicesStartExecutor; -------------------------------------------------------------------------------- /src/system/Network.ts: -------------------------------------------------------------------------------- 1 | import os from "os"; 2 | 3 | type TNetwork = Record; 11 | 12 | class Network { 13 | 14 | public static getNetwork(): TNetwork { 15 | 16 | const network: TNetwork = {}; 17 | 18 | try { 19 | const interfaces = os.networkInterfaces(); 20 | 21 | for (const ifaceName in interfaces) { 22 | const iface = interfaces[ifaceName]!; 23 | network[ifaceName] = { 24 | mac_address: iface[0].mac, 25 | ip_addresses: iface.map((address) => ({ 26 | family: address.family.toLowerCase(), 27 | address: address.address, 28 | is_link_local: address.scopeid === 0x2 29 | })) 30 | }; 31 | } 32 | } catch (_) { 33 | // 34 | } 35 | 36 | return network; 37 | } 38 | } 39 | 40 | export { TNetwork }; 41 | export default Network; -------------------------------------------------------------------------------- /src/printer/objects/IdleTimeoutObject.ts: -------------------------------------------------------------------------------- 1 | import PrinterObject from "./PrinterObject"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | type TIdleState = "Idle" | "Ready" | "Printing"; 5 | 6 | interface IObject { 7 | state: TIdleState; 8 | printing_time: number; 9 | } 10 | 11 | class IdleTimeoutObject extends PrinterObject { 12 | 13 | public readonly name = "idle_timeout"; 14 | private readonly marlinRaker: MarlinRaker; 15 | 16 | public constructor(marlinRaker: MarlinRaker) { 17 | super(); 18 | this.marlinRaker = marlinRaker; 19 | 20 | this.marlinRaker.jobManager.on("stateChange", this.emit.bind(this)); 21 | this.marlinRaker.jobManager.on("durationUpdate", this.emit.bind(this)); 22 | } 23 | 24 | protected get(): IObject { 25 | return { 26 | state: this.marlinRaker.jobManager.state === "printing" ? "Printing" : "Idle", 27 | printing_time: this.marlinRaker.jobManager.printDuration 28 | }; 29 | } 30 | 31 | public isAvailable(): boolean { 32 | return this.marlinRaker.state === "ready"; 33 | } 34 | } 35 | 36 | export default IdleTimeoutObject; -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Welcome 2 | 3 | ## Welcome to Marlinraker! 4 | Marlinraker allows you to use your favorite Klipper-based frontends 5 | with your Marlin 3D printer. 6 | 7 | [Installation](installation.md){ .md-button .md-button--primary } 8 | [GitHub](https://github.com/pauhull/marlinraker){ .md-button } 9 | 10 | Marlinraker can connect to 3D printers running Marlin firmware, for 11 | example Prusa or Creality machines. It runs on an external host device, 12 | like a Raspberry Pi, and connects to your printer via USB. Marlinraker works 13 | by replicating the [Moonraker](https://github.com/Arksine/moonraker) API. 14 | Because it runs on the Node.js runtime it can be used on low-power SBCs like the 15 | Raspberry Pi Zero and even on Windows machines! 16 | 17 | Software that can be used with Marlinraker includes: 18 | 19 | - [Mainsail](https://github.com/mainsail-crew/mainsail) 20 | - [Fluidd](https://github.com/fluidd-core/fluidd) 21 | - [KlipperScreen](https://github.com/jordanruthe/KlipperScreen) 22 | - [Mooncord](https://github.com/eliteSchwein/mooncord) 23 | - ... and much more! 24 | 25 | Special thanks to Arksine for creating Moonraker, which this project wouldn't be possible without. -------------------------------------------------------------------------------- /src/api/executors/PrinterPrintStartExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | interface IParams { 5 | filename: string; 6 | } 7 | 8 | class PrinterPrintStartExecutor implements IMethodExecutor { 9 | 10 | public readonly name = "printer.print.start"; 11 | public readonly httpMethod = "post"; 12 | public readonly timeout = null; 13 | private readonly marlinRaker: MarlinRaker; 14 | 15 | public constructor(marlinRaker: MarlinRaker) { 16 | this.marlinRaker = marlinRaker; 17 | } 18 | 19 | public async invoke(_: TSender, params: Partial): Promise { 20 | if (this.marlinRaker.state !== "ready") throw new Error("Printer not ready"); 21 | if (!params.filename) throw new Error("Invalid filename"); 22 | if (await this.marlinRaker.jobManager.selectFile(`gcodes/${params.filename}`)) { 23 | await this.marlinRaker.dispatchCommand("start_print", false); 24 | return "ok"; 25 | } else { 26 | throw new Error("Could not start print"); 27 | } 28 | } 29 | } 30 | 31 | export default PrinterPrintStartExecutor; -------------------------------------------------------------------------------- /src/printer/objects/HeatersObject.ts: -------------------------------------------------------------------------------- 1 | import PrinterObject from "./PrinterObject"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | interface IObject { 5 | available_sensors: string[]; 6 | available_heaters: string[]; 7 | } 8 | 9 | class HeatersObject extends PrinterObject { 10 | 11 | public readonly name = "heaters"; 12 | private readonly marlinRaker: MarlinRaker; 13 | 14 | public constructor(marlinRaker: MarlinRaker) { 15 | super(); 16 | this.marlinRaker = marlinRaker; 17 | 18 | this.marlinRaker.on("stateChange", (state) => { 19 | if (state === "ready") { 20 | this.marlinRaker.printer?.heaterManager.on("availableSensorsUpdate", this.emit.bind(this)); 21 | } 22 | }); 23 | } 24 | 25 | protected get(): IObject { 26 | return { 27 | available_sensors: this.marlinRaker.printer?.heaterManager.availableSensors ?? [], 28 | available_heaters: this.marlinRaker.printer?.heaterManager.availableHeaters ?? [] 29 | }; 30 | } 31 | 32 | public isAvailable(): boolean { 33 | return this.marlinRaker.state === "ready"; 34 | } 35 | } 36 | 37 | export default HeatersObject; -------------------------------------------------------------------------------- /src/printer/objects/MotionReportObject.ts: -------------------------------------------------------------------------------- 1 | import PrinterObject from "./PrinterObject"; 2 | import { TVec4 } from "../../util/Utils"; 3 | import MarlinRaker from "../../MarlinRaker"; 4 | 5 | interface IObject { 6 | live_position: TVec4; 7 | live_velocity: number; 8 | live_extruder_velocity: number; 9 | } 10 | 11 | class MotionReportObject extends PrinterObject { 12 | 13 | public readonly name = "motion_report"; 14 | private readonly marlinRaker: MarlinRaker; 15 | 16 | public constructor(marlinRaker: MarlinRaker) { 17 | super(); 18 | this.marlinRaker = marlinRaker; 19 | 20 | setInterval(() => { 21 | if (this.isAvailable()) this.emit(); 22 | }, 250); 23 | } 24 | 25 | protected get(): IObject { 26 | return { 27 | live_position: this.marlinRaker.printer?.actualPosition ?? [0, 0, 0, 0], 28 | live_velocity: this.marlinRaker.printer?.actualVelocity ?? 0, 29 | live_extruder_velocity: this.marlinRaker.printer?.actualExtruderVelocity ?? 0 30 | }; 31 | } 32 | 33 | public isAvailable(): boolean { 34 | return this.marlinRaker.state === "ready"; 35 | } 36 | } 37 | 38 | export default MotionReportObject; -------------------------------------------------------------------------------- /src/api/executors/MachineSystemInfoExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import { IMachineInfo } from "../../system/SystemInfo"; 3 | import MarlinRaker from "../../MarlinRaker"; 4 | import { IActiveService } from "../../system/ServiceManager"; 5 | 6 | interface IResult { 7 | system_info: IMachineInfo & { 8 | available_services: string[]; 9 | service_state: Record; 10 | }; 11 | } 12 | 13 | class MachineSystemInfoExecutor implements IMethodExecutor { 14 | 15 | public readonly name = "machine.system_info"; 16 | private readonly marlinRaker: MarlinRaker; 17 | 18 | public constructor(marlinRaker: MarlinRaker) { 19 | this.marlinRaker = marlinRaker; 20 | } 21 | 22 | public invoke(_: TSender, __: undefined): IResult { 23 | return { 24 | system_info: { 25 | ...this.marlinRaker.systemInfo.machineInfo, 26 | available_services: this.marlinRaker.systemInfo.serviceManager.activeServiceList, 27 | service_state: this.marlinRaker.systemInfo.serviceManager.activeServices 28 | } 29 | }; 30 | } 31 | } 32 | 33 | export default MachineSystemInfoExecutor; -------------------------------------------------------------------------------- /src/api/executors/ServerHistoryListExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import { IHistoryJob } from "../../printer/jobs/JobHistory"; 3 | import MarlinRaker from "../../MarlinRaker"; 4 | 5 | interface IParams { 6 | limit: number; 7 | start: number; 8 | since: number; 9 | before: number; 10 | order: string; 11 | } 12 | 13 | interface IResult { 14 | count: number; 15 | jobs: IHistoryJob[]; 16 | } 17 | 18 | class ServerHistoryListExecutor implements IMethodExecutor { 19 | 20 | public readonly name = "server.history.list"; 21 | private readonly marlinRaker: MarlinRaker; 22 | 23 | public constructor(marlinRaker: MarlinRaker) { 24 | this.marlinRaker = marlinRaker; 25 | } 26 | 27 | public async invoke(_: TSender, params: Partial): Promise { 28 | const jobs = await this.marlinRaker.jobHistory.getPrintHistory(params.limit ?? 50, params.start ?? 0, 29 | params.since ?? -Infinity, params.before ?? Infinity, 30 | params.order === "asc" ? "asc" : "desc"); 31 | return { 32 | count: jobs.length, 33 | jobs 34 | }; 35 | } 36 | } 37 | 38 | export default ServerHistoryListExecutor; -------------------------------------------------------------------------------- /src/api/executors/ServerFilesGetDirectoryExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import { IDirInfo } from "../../files/FileManager"; 3 | import MarlinRaker from "../../MarlinRaker"; 4 | import { rootDir } from "../../Server"; 5 | import path from "path"; 6 | 7 | interface IParams { 8 | path: string; 9 | extended: boolean; 10 | } 11 | 12 | class ServerFilesGetDirectoryExecutor implements IMethodExecutor { 13 | 14 | public readonly name = "server.files.get_directory"; 15 | private readonly marlinRaker: MarlinRaker; 16 | 17 | public constructor(marlinRaker: MarlinRaker) { 18 | this.marlinRaker = marlinRaker; 19 | } 20 | 21 | public async invoke(_: TSender, params: Partial): Promise { 22 | const dirname = params.path ?? "gcodes"; 23 | return await this.marlinRaker.fileManager.getDirectoryInfo(dirname) ?? { 24 | dirs: [], 25 | files: [], 26 | disk_usage: await this.marlinRaker.fileManager.getDiskUsage(path.join(rootDir, dirname)), 27 | root_info: { 28 | name: dirname, 29 | permissions: "r" 30 | } 31 | }; 32 | } 33 | } 34 | 35 | export default ServerFilesGetDirectoryExecutor; -------------------------------------------------------------------------------- /src/api/executors/ServerJobQueueStatusExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import { TQueueState } from "../../printer/jobs/JobQueue"; 3 | import MarlinRaker from "../../MarlinRaker"; 4 | 5 | interface IResult { 6 | queued_jobs: { 7 | filename: string; 8 | job_id: string; 9 | time_added: number; 10 | time_in_queue: number; 11 | }[]; 12 | queue_state: TQueueState; 13 | } 14 | 15 | class ServerJobQueueStatusExecutor implements IMethodExecutor { 16 | 17 | public readonly name = "server.job_queue.status"; 18 | private readonly marlinRaker: MarlinRaker; 19 | 20 | public constructor(marlinRaker: MarlinRaker) { 21 | this.marlinRaker = marlinRaker; 22 | } 23 | 24 | public invoke(_: TSender, __: undefined): IResult { 25 | return { 26 | queue_state: this.marlinRaker.jobManager.jobQueue.state, 27 | queued_jobs: this.marlinRaker.jobManager.jobQueue.queue.map((job) => ({ 28 | filename: job.filename, 29 | job_id: job.jobId, 30 | time_added: job.timeAdded, 31 | time_in_queue: job.timeInQueue 32 | })) 33 | }; 34 | } 35 | } 36 | 37 | export default ServerJobQueueStatusExecutor; -------------------------------------------------------------------------------- /src/api/executors/PrinterObjectsQueryExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | import { IPrinterObjects } from "../../printer/objects/ObjectManager"; 4 | 5 | interface IParams { 6 | [key: string]: unknown; 7 | objects: Record; 8 | } 9 | 10 | class PrinterObjectsQueryExecutor implements IMethodExecutor { 11 | 12 | public readonly name = "printer.objects.query"; 13 | private readonly marlinRaker: MarlinRaker; 14 | 15 | public constructor(marlinRaker: MarlinRaker) { 16 | this.marlinRaker = marlinRaker; 17 | } 18 | 19 | public invoke(_: TSender, params: Partial): IPrinterObjects { 20 | 21 | let objects: Record = {}; 22 | if (params.objects) { 23 | objects = params.objects; 24 | } else { 25 | for (const key in params) { 26 | objects[key] = params[key] ? String(params[key]).split(",") : null; 27 | } 28 | } 29 | 30 | if (!Object.keys(objects).length) throw new Error("No objects provided"); 31 | return this.marlinRaker.objectManager.query(objects); 32 | } 33 | } 34 | 35 | export default PrinterObjectsQueryExecutor; -------------------------------------------------------------------------------- /src/api/executors/ServerConnectionIdentifyExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import WebSocket from "ws"; 3 | import MarlinRaker from "../../MarlinRaker"; 4 | 5 | interface IParams { 6 | client_name: string; 7 | version: string; 8 | type: string; 9 | url: string; 10 | } 11 | 12 | interface IResult { 13 | connection_id: number; 14 | } 15 | 16 | class ServerConnectionIdentifyExecutor implements IMethodExecutor { 17 | 18 | public readonly name = "server.connection.identify"; 19 | public readonly httpMethod = null; 20 | private readonly marlinRaker: MarlinRaker; 21 | 22 | public constructor(marlinRaker: MarlinRaker) { 23 | this.marlinRaker = marlinRaker; 24 | } 25 | 26 | public invoke(sender: TSender, params: Partial): IResult | null { 27 | if (!params.client_name || !params.version || !params.type || !params.url) throw new Error("Incomplete params"); 28 | if (!(sender instanceof WebSocket)) return null; 29 | const connection = this.marlinRaker.connectionManager.registerConnection(sender, params.client_name, params.version, params.type, params.url); 30 | return { connection_id: connection.connectionId }; 31 | } 32 | } 33 | 34 | export default ServerConnectionIdentifyExecutor; -------------------------------------------------------------------------------- /src/api/executors/ServerDatabaseDeleteItemExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | interface IParams { 5 | namespace: string; 6 | key: string; 7 | } 8 | 9 | interface IResult { 10 | namespace: string; 11 | key: string | null; 12 | value: unknown; 13 | } 14 | 15 | class ServerDatabaseDeleteItemExecutor implements IMethodExecutor { 16 | 17 | public readonly name = "server.database.delete_item"; 18 | public readonly httpName = "server.database.item"; 19 | public readonly httpMethod = "delete"; 20 | private readonly marlinRaker: MarlinRaker; 21 | 22 | public constructor(marlinRaker: MarlinRaker) { 23 | this.marlinRaker = marlinRaker; 24 | } 25 | 26 | public async invoke(_: TSender, params: Partial): Promise { 27 | if (!params.namespace) throw new Error("Invalid namespace"); 28 | if (!params.key) throw new Error("Invalid key"); 29 | const value = await this.marlinRaker.database.deleteItem(params.namespace, params.key); 30 | return { 31 | namespace: params.namespace, 32 | key: params.key, 33 | value 34 | }; 35 | } 36 | } 37 | 38 | export default ServerDatabaseDeleteItemExecutor; -------------------------------------------------------------------------------- /src/api/executors/ServerDatabasePostItemExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | interface IParams { 5 | namespace: string; 6 | key: string; 7 | value: unknown; 8 | } 9 | 10 | interface IResult { 11 | namespace: string; 12 | key: string | null; 13 | value: unknown; 14 | } 15 | 16 | class ServerDatabasePostItemExecutor implements IMethodExecutor { 17 | 18 | public readonly name = "server.database.post_item"; 19 | public readonly httpName = "server.database.item"; 20 | public readonly httpMethod = "post"; 21 | private readonly marlinRaker: MarlinRaker; 22 | 23 | public constructor(marlinRaker: MarlinRaker) { 24 | this.marlinRaker = marlinRaker; 25 | } 26 | 27 | public async invoke(_: TSender, params: Partial): Promise { 28 | if (!params.namespace) throw new Error("Invalid namespace"); 29 | if (!params.key) throw new Error("Invalid key"); 30 | return { 31 | namespace: params.namespace, 32 | key: params.key, 33 | value: await this.marlinRaker.database.addItem(params.namespace, params.key, params.value) 34 | }; 35 | } 36 | } 37 | 38 | export default ServerDatabasePostItemExecutor; -------------------------------------------------------------------------------- /src/util/TypedEventEmitter.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "events"; 2 | 3 | type TEvents = Record void>; 4 | 5 | declare interface TypedEventEmitter> { 6 | addListener: (eventName: T, listener: E[T]) => this; 7 | emit: (eventName: T, ...args: Parameters) => boolean; 8 | eventNames: () => (keyof E)[]; 9 | getMaxListeners: () => number; 10 | listenerCount: (eventName: keyof E) => number; 11 | listeners: (eventName: T) => E[T][]; 12 | off: (eventName: T, listener: E[T]) => this; 13 | on: (eventName: T, listener: E[T]) => this; 14 | once: (eventName: T, listener: E[T]) => this; 15 | prependListener: (eventName: T, listener: E[T]) => this; 16 | prependOnceListener: (eventName: T, listener: E[T]) => this; 17 | rawListeners: (eventName: keyof E) => (...args: any[]) => any; 18 | removeAllListeners: (eventName?: keyof E) => this; 19 | removeListener: (eventName: T, listener: E[T]) => this; 20 | setMaxListeners: (n: number) => this; 21 | } 22 | 23 | class TypedEventEmitter> extends (EventEmitter as new() => {}) {} 24 | 25 | export default TypedEventEmitter; -------------------------------------------------------------------------------- /src/api/executors/ServerDatabaseGetItemExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | interface IParams { 5 | namespace: string; 6 | key: string | null; 7 | } 8 | 9 | interface IResult { 10 | namespace: string; 11 | key: string | null; 12 | value: unknown; 13 | } 14 | 15 | class ServerDatabaseGetItemExecutor implements IMethodExecutor { 16 | 17 | public readonly name = "server.database.get_item"; 18 | public readonly httpName = "server.database.item"; 19 | private readonly marlinRaker: MarlinRaker; 20 | 21 | public constructor(marlinRaker: MarlinRaker) { 22 | this.marlinRaker = marlinRaker; 23 | } 24 | 25 | public async invoke(_: TSender, params: Partial): Promise { 26 | if (!params.namespace) throw new Error("Invalid namespace"); 27 | const value = await this.marlinRaker.database.getItem(params.namespace, params.key ?? undefined); 28 | if (value === null) throw new Error(`${params.namespace}${params.key ? `.${params.key}` : ""} doesn't exist`); 29 | return { 30 | namespace: params.namespace, 31 | key: params.key ?? null, 32 | value 33 | }; 34 | } 35 | 36 | } 37 | 38 | export default ServerDatabaseGetItemExecutor; -------------------------------------------------------------------------------- /src/printer/objects/VirtualSdCardObject.ts: -------------------------------------------------------------------------------- 1 | import PrinterObject from "./PrinterObject"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | interface IObject { 5 | is_active: boolean; 6 | progress: number; 7 | file_path: string; 8 | file_position: number; 9 | file_size: number; 10 | } 11 | 12 | class VirtualSdCardObject extends PrinterObject { 13 | 14 | public readonly name = "virtual_sdcard"; 15 | private readonly marlinRaker: MarlinRaker; 16 | 17 | public constructor(marlinRaker: MarlinRaker) { 18 | super(); 19 | this.marlinRaker = marlinRaker; 20 | 21 | this.marlinRaker.jobManager.on("stateChange", this.emit.bind(this)); 22 | this.marlinRaker.jobManager.on("progressUpdate", this.emit.bind(this)); 23 | } 24 | 25 | protected get(): IObject { 26 | return { 27 | is_active: this.marlinRaker.jobManager.state === "printing", 28 | progress: this.marlinRaker.jobManager.currentPrintJob?.progress ?? 0, 29 | file_path: this.marlinRaker.jobManager.currentPrintJob?.filepath ?? "", 30 | file_position: this.marlinRaker.jobManager.currentPrintJob?.filePosition ?? 0, 31 | file_size: this.marlinRaker.jobManager.currentPrintJob?.fileSize ?? 0 32 | }; 33 | } 34 | 35 | public isAvailable(): boolean { 36 | return this.marlinRaker.state === "ready"; 37 | } 38 | } 39 | 40 | export default VirtualSdCardObject; -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # configuration file for git-cliff (0.1.0) 2 | 3 | [changelog] 4 | header = """ 5 | # Changelog\n 6 | All notable changes to Marlinraker will be documented in this file.\n 7 | """ 8 | body = """ 9 | {% if version %}\ 10 | ## {{ version | trim_start_matches(pat="v") }} - {{ timestamp | date(format="%Y-%m-%d") }} 11 | {% else %}\ 12 | ## [unreleased] 13 | {% endif %}\ 14 | {% for group, commits in commits | group_by(attribute="group") %} 15 | ### {{ group | upper_first }} 16 | {% for commit in commits %} 17 | - {{ commit.message | upper_first | trim }} ([{{ commit.id | truncate(length=7, end="") }}]({{ commit.id }}))\ 18 | {% endfor %} 19 | {% endfor %}\n 20 | """ 21 | trim = true 22 | footer = """ 23 | """ 24 | 25 | [git] 26 | conventional_commits = true 27 | filter_unconventional = true 28 | split_commits = false 29 | commit_parsers = [ 30 | { message = "^feat", group = "Features"}, 31 | { message = "^fix", group = "Bug Fixes"}, 32 | { message = "^doc", group = "Documentation"}, 33 | { message = "^perf", group = "Performance"}, 34 | { message = "^refactor", group = "Refactor"}, 35 | { message = "^style", group = "Styling"}, 36 | { message = "^test", group = "Testing"}, 37 | { message = "^chore", group = "Miscellaneous Tasks", skip = true}, 38 | { body = ".*security", group = "Security"}, 39 | ] 40 | protect_breaking_commits = false 41 | filter_commits = true 42 | date_order = true 43 | sort_commits = "oldest" 44 | -------------------------------------------------------------------------------- /src/printer/objects/PrintStatsObject.ts: -------------------------------------------------------------------------------- 1 | import PrinterObject from "./PrinterObject"; 2 | import MarlinRaker from "../../MarlinRaker"; 3 | 4 | type TPrintState = "standby" | "printing" | "paused" | "complete" | "cancelled" | "error"; 5 | 6 | interface IObject { 7 | filename: string; 8 | total_duration: number; 9 | print_duration: number; 10 | filament_used: number; 11 | state: TPrintState; 12 | message: string; 13 | } 14 | 15 | class PrintStatsObject extends PrinterObject { 16 | 17 | private readonly marlinRaker: MarlinRaker; 18 | public readonly name = "print_stats"; 19 | 20 | public constructor(marlinRaker: MarlinRaker) { 21 | super(); 22 | this.marlinRaker = marlinRaker; 23 | 24 | this.marlinRaker.jobManager.on("stateChange", this.emit.bind(this)); 25 | this.marlinRaker.jobManager.on("durationUpdate", this.emit.bind(this)); 26 | } 27 | 28 | protected get(): IObject { 29 | return { 30 | filename: this.marlinRaker.jobManager.currentPrintJob?.filename ?? "", 31 | total_duration: this.marlinRaker.jobManager.totalDuration, 32 | print_duration: this.marlinRaker.jobManager.printDuration, 33 | filament_used: this.marlinRaker.jobManager.getFilamentUsed(), 34 | state: this.marlinRaker.jobManager.state, 35 | message: "" 36 | }; 37 | } 38 | 39 | public isAvailable(): boolean { 40 | return this.marlinRaker.state === "ready"; 41 | } 42 | } 43 | 44 | export default PrintStatsObject; -------------------------------------------------------------------------------- /src/connection/ConnectionManager.ts: -------------------------------------------------------------------------------- 1 | import WebSocket from "ws"; 2 | 3 | interface IConnection { 4 | connectionId: number; 5 | socket: WebSocket; 6 | clientName: string; 7 | version: string; 8 | type: string; 9 | url: string; 10 | } 11 | 12 | class ConnectionManager { 13 | 14 | public connections: IConnection[]; 15 | 16 | public constructor() { 17 | this.connections = []; 18 | } 19 | 20 | public registerConnection(socket: WebSocket, clientName: string, version: string, type: string, url: string): IConnection { 21 | const connection: IConnection = { 22 | connectionId: this.findConnectionId(), 23 | socket, 24 | clientName, 25 | version, 26 | type, 27 | url 28 | }; 29 | this.connections.push(connection); 30 | socket.on("close", () => { 31 | this.connections = this.connections.filter((c) => c !== connection); 32 | }); 33 | return connection; 34 | } 35 | 36 | public findConnectionById(connectionId: number): IConnection | null { 37 | return this.connections.find((connection) => connection.connectionId === connectionId) ?? null; 38 | } 39 | 40 | private findConnectionId(): number { 41 | let connectionId: number; 42 | do { 43 | connectionId = 1e9 + Math.floor(Math.random() * (9e9 - 1)); 44 | } while (this.connections.some((connection) => connection.connectionId === connectionId)); 45 | return connectionId; 46 | } 47 | } 48 | 49 | export { IConnection }; 50 | export default ConnectionManager; -------------------------------------------------------------------------------- /src/util/Utils.ts: -------------------------------------------------------------------------------- 1 | type TVec2 = [number, number]; 2 | type TVec3 = [number, number, number]; 3 | type TVec4 = [number, number, number, number]; 4 | 5 | class Utils { 6 | public static errorToString(e: unknown): string { 7 | if (e instanceof Error) { 8 | return e.message; 9 | } 10 | return String(e); 11 | } 12 | 13 | public static getDeepKeys(obj: T): string[] { 14 | const keys = []; 15 | for (const key in obj) { 16 | const value = obj[key]; 17 | if (typeof value === "object" && !Array.isArray(value)) { 18 | keys.push(key); 19 | const subKeys = Utils.getDeepKeys(value); 20 | keys.push(...subKeys.map((s) => `${key}.${s}`)); 21 | } 22 | } 23 | return keys; 24 | } 25 | 26 | public static toLowerCaseKeys(obj: T): object { 27 | const withLowercaseKeys: Record = {}; 28 | for (const key in obj) { 29 | const value = obj[key]; 30 | withLowercaseKeys[key.toLowerCase()] = typeof value === "object" && !Array.isArray(value) 31 | ? this.toLowerCaseKeys(value) : value; 32 | } 33 | return withLowercaseKeys; 34 | } 35 | 36 | public static async promisify(fn: (cb: (t: T) => void) => void): Promise { 37 | return new Promise((resolve, reject) => { 38 | try { 39 | fn(resolve); 40 | } catch (e) { 41 | reject(e); 42 | } 43 | }); 44 | } 45 | } 46 | 47 | export { TVec2, TVec3, TVec4 }; 48 | export default Utils; -------------------------------------------------------------------------------- /src/printer/objects/GcodeMoveObject.ts: -------------------------------------------------------------------------------- 1 | import PrinterObject from "./PrinterObject"; 2 | import { TVec4 } from "../../util/Utils"; 3 | import MarlinRaker from "../../MarlinRaker"; 4 | 5 | interface IObject { 6 | speed_factor: number; 7 | speed: number; 8 | extrude_factor: number; 9 | absolute_coordinates: boolean; 10 | absolute_extrude: boolean; 11 | homing_origin: TVec4; 12 | position: TVec4; 13 | gcode_position: TVec4; 14 | } 15 | 16 | class GcodeMoveObject extends PrinterObject { 17 | 18 | public readonly name = "gcode_move"; 19 | private readonly marlinRaker: MarlinRaker; 20 | 21 | public constructor(marlinRaker: MarlinRaker) { 22 | super(); 23 | this.marlinRaker = marlinRaker; 24 | 25 | setInterval(() => { 26 | if (this.isAvailable()) this.emit(); 27 | }, 250); 28 | } 29 | 30 | protected get(): IObject { 31 | return { 32 | speed_factor: this.marlinRaker.printer?.speedFactor ?? 1, 33 | speed: 0.0, 34 | extrude_factor: this.marlinRaker.printer?.extrudeFactor ?? 1, 35 | absolute_coordinates: this.marlinRaker.printer?.isAbsolutePositioning ?? true, 36 | absolute_extrude: this.marlinRaker.printer?.isAbsolutePositioning ?? true, 37 | homing_origin: [0, 0, 0, 0], 38 | position: this.marlinRaker.printer?.gcodePosition ?? [0, 0, 0, 0], 39 | gcode_position: this.marlinRaker.printer?.gcodePosition ?? [0, 0, 0, 0] 40 | }; 41 | } 42 | 43 | public isAvailable(): boolean { 44 | return this.marlinRaker.state === "ready"; 45 | } 46 | } 47 | 48 | export default GcodeMoveObject; -------------------------------------------------------------------------------- /.github/workflows/publish_docker.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish Docker image 2 | 3 | on: 4 | push: 5 | branches: [ "master", "develop" ] 6 | tags: 7 | - "*" 8 | pull_request: 9 | branches: 10 | - '*' 11 | 12 | env: 13 | REGISTRY: ghcr.io 14 | IMAGE_NAME: ${{ github.repository }} 15 | 16 | jobs: 17 | docker: 18 | runs-on: ubuntu-latest 19 | permissions: 20 | packages: write 21 | contents: read 22 | 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v3 26 | 27 | - name: Log in to the Container registry 28 | uses: docker/login-action@v2 29 | with: 30 | registry: ${{ env.REGISTRY }} 31 | username: ${{ github.actor }} 32 | password: ${{ secrets.GITHUB_TOKEN }} 33 | 34 | - name: Extract metadata (tags, labels) for Docker 35 | id: meta 36 | uses: docker/metadata-action@v3 37 | with: 38 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 39 | tags: | 40 | type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }} 41 | type=edge,branch=develop 42 | type=ref,event=tag 43 | 44 | - name: Set up QEMU 45 | uses: docker/setup-qemu-action@v2 46 | 47 | - name: Set up Docker Buildx 48 | uses: docker/setup-buildx-action@v2 49 | 50 | - name: Build and push 51 | uses: docker/build-push-action@v4 52 | with: 53 | platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 54 | push: ${{ github.event_name != 'pull_request' }} 55 | tags: ${{ steps.meta.outputs.tags }} 56 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /src/printer/watchers/TemperatureWatcher.ts: -------------------------------------------------------------------------------- 1 | import Printer from "../Printer"; 2 | import ParserUtil from "../ParserUtil"; 3 | import Watcher from "./Watcher"; 4 | 5 | class TemperatureWatcher extends Watcher { 6 | 7 | private readonly printer: Printer; 8 | private readonly autoReport: boolean; 9 | private readonly timer?: NodeJS.Timer; 10 | 11 | public constructor(printer: Printer) { 12 | super(); 13 | this.printer = printer; 14 | this.autoReport = printer.capabilities.AUTOREPORT_TEMP ?? false; 15 | 16 | if (this.autoReport) { 17 | if (!printer.isPrusa) { 18 | void this.printer.queueGcode("M155 S1", false, false); 19 | } 20 | } else { 21 | let requested = false; 22 | this.timer = setInterval(async () => { 23 | if (requested) return; 24 | requested = true; 25 | const response = await this.printer.queueGcode("M105", true, false); 26 | requested = false; 27 | this.readTemps(response); 28 | }, 1000); 29 | } 30 | } 31 | 32 | private readTemps(data: string): void { 33 | const heaters = ParserUtil.parseM105Response(data); 34 | if (!heaters) return; 35 | this.printer.heaterManager.updateTemps(heaters); 36 | super.onLoaded(); 37 | } 38 | 39 | public handle(line: string): boolean { 40 | if (!line.trim().startsWith("T")) return false; 41 | this.readTemps(line); 42 | return true; 43 | } 44 | 45 | public cleanup(): void { 46 | clearInterval(this.timer); 47 | } 48 | } 49 | 50 | export default TemperatureWatcher; -------------------------------------------------------------------------------- /src/printer/objects/ConfigFileObject.ts: -------------------------------------------------------------------------------- 1 | import PrinterObject from "./PrinterObject"; 2 | import { config } from "../../Server"; 3 | import Utils from "../../util/Utils"; 4 | import MarlinRaker from "../../MarlinRaker"; 5 | 6 | interface IObject { 7 | config: unknown; 8 | settings: unknown; 9 | save_config_pending: boolean; 10 | } 11 | 12 | class ConfigFileObject extends PrinterObject { 13 | 14 | public readonly name = "configfile"; 15 | private readonly marlinRaker: MarlinRaker; 16 | 17 | public constructor(marlinRaker: MarlinRaker) { 18 | super(); 19 | this.marlinRaker = marlinRaker; 20 | 21 | this.marlinRaker.on("stateChange", this.emit.bind(this)); 22 | } 23 | 24 | protected get(): IObject { 25 | let klipperPseudoConfig = { ...config.klipperPseudoConfig }; 26 | 27 | if (this.marlinRaker.printer) { 28 | const limits = this.marlinRaker.printer.limits; 29 | klipperPseudoConfig = { 30 | ...klipperPseudoConfig, 31 | printer: { 32 | kinematics: "cartesian", 33 | max_velocity: Math.min(limits.maxFeedrate[0], limits.maxFeedrate[1]), 34 | max_accel: Math.min(limits.maxAccel[1], limits.maxAccel[1]), 35 | max_z_velocity: limits.maxFeedrate[2], 36 | max_z_accel: limits.maxAccel[2] 37 | } 38 | }; 39 | } 40 | 41 | return { 42 | config: klipperPseudoConfig, 43 | settings: Utils.toLowerCaseKeys(klipperPseudoConfig), 44 | save_config_pending: false 45 | }; 46 | } 47 | } 48 | 49 | export default ConfigFileObject; -------------------------------------------------------------------------------- /src/database/Database.ts: -------------------------------------------------------------------------------- 1 | import { JsonDB } from "node-json-db"; 2 | import { Config } from "node-json-db/dist/lib/JsonDBConfig"; 3 | import path from "path"; 4 | import { rootDir } from "../Server"; 5 | 6 | class Database { 7 | 8 | private readonly reservedNamespaces = ["marlinraker", "moonraker", "gcode_metadata", "history"]; 9 | private readonly jsonDb; 10 | 11 | public constructor() { 12 | this.jsonDb = new JsonDB(new Config(path.join(rootDir, "database.json"), true, true, ".")); 13 | } 14 | 15 | public async getNamespaces(): Promise { 16 | const root = await this.jsonDb.getData("."); 17 | return [...this.reservedNamespaces, ...Object.keys(root)]; 18 | } 19 | 20 | private static getPath(namespace: string, key?: string): string { 21 | return `.${namespace}${key ? `.${key}` : ""}`; 22 | } 23 | 24 | public async getItem(namespace: string, key?: string): Promise { 25 | try { 26 | return await this.jsonDb.getData(Database.getPath(namespace, key)); 27 | } catch (e) { 28 | return null; 29 | } 30 | } 31 | 32 | public async addItem(namespace: string, key: string | undefined, value: unknown): Promise { 33 | await this.jsonDb.push(Database.getPath(namespace, key), value, true); 34 | return value; 35 | } 36 | 37 | public async deleteItem(namespace: string, key: string): Promise { 38 | const value = this.getItem(namespace, key); 39 | try { 40 | await this.jsonDb.delete(Database.getPath(namespace, key)); 41 | } catch (e) { 42 | return null; 43 | } 44 | return value; 45 | } 46 | } 47 | 48 | export default Database; -------------------------------------------------------------------------------- /src/api/executors/PrinterObjectsSubscribeExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import { WebSocket } from "ws"; 3 | import MarlinRaker from "../../MarlinRaker"; 4 | import { IPrinterObjects } from "../../printer/objects/ObjectManager"; 5 | 6 | interface IParams { 7 | [key: string]: unknown; 8 | objects: Record; 9 | connection_id: number; 10 | } 11 | 12 | class PrinterObjectsSubscribeExecutor implements IMethodExecutor { 13 | 14 | public readonly name = "printer.objects.subscribe"; 15 | public readonly httpMethod = "post"; 16 | private readonly marlinRaker: MarlinRaker; 17 | 18 | public constructor(marlinRaker: MarlinRaker) { 19 | this.marlinRaker = marlinRaker; 20 | } 21 | 22 | public invoke(sender: TSender, params: Partial): IPrinterObjects { 23 | 24 | let socket: WebSocket | undefined; 25 | if (sender instanceof WebSocket) { 26 | socket = sender; 27 | } else if (params.connection_id) { 28 | socket = this.marlinRaker.connectionManager.findConnectionById(params.connection_id)?.socket; 29 | } 30 | if (!socket) { 31 | throw new Error("Cannot identify socket"); 32 | } 33 | 34 | let objects: Record = {}; 35 | if (params.objects) { 36 | objects = params.objects; 37 | } else { 38 | for (const key in params) { 39 | const topics = String(params[key]).split(","); 40 | objects[key] = topics.length > 0 ? topics : null; 41 | } 42 | } 43 | 44 | return this.marlinRaker.objectManager.subscribe(socket, objects); 45 | } 46 | } 47 | 48 | export default PrinterObjectsSubscribeExecutor; -------------------------------------------------------------------------------- /src/printer/objects/BedMeshObject.ts: -------------------------------------------------------------------------------- 1 | import PrinterObject from "./PrinterObject"; 2 | import { TVec2 } from "../../util/Utils"; 3 | import MarlinRaker from "../../MarlinRaker"; 4 | import { config } from "../../Server"; 5 | 6 | interface IObject { 7 | profile_name: string; 8 | mesh_min: TVec2; 9 | mesh_max: TVec2; 10 | probed_matrix?: number[][]; 11 | mesh_matrix?: number[][]; 12 | } 13 | 14 | class BedMeshObject extends PrinterObject { 15 | 16 | public readonly name = "bed_mesh"; 17 | private readonly marlinRaker: MarlinRaker; 18 | private readonly isBedMesh: boolean; 19 | private readonly profile?: string; 20 | private readonly min: TVec2; 21 | private readonly max: TVec2; 22 | private readonly grid: number[][]; 23 | 24 | public constructor(marlinRaker: MarlinRaker) { 25 | super(); 26 | 27 | this.marlinRaker = marlinRaker; 28 | this.isBedMesh = config.getBoolean("printer.bed_mesh", false); 29 | this.min = [0, 0]; 30 | this.max = [0, 0]; 31 | this.grid = [[]]; 32 | 33 | if (this.isBedMesh) { 34 | this.marlinRaker.on("stateChange", (state) => { 35 | if (state === "ready") { 36 | this.marlinRaker.printer?.on("updateBedMesh", (bedMesh) => { 37 | Object.assign(this, bedMesh); 38 | this.emit(); 39 | }); 40 | } 41 | }); 42 | } 43 | } 44 | 45 | protected get(): IObject { 46 | return { 47 | profile_name: this.profile ?? "", 48 | mesh_min: this.min, 49 | mesh_max: this.max, 50 | mesh_matrix: this.grid, 51 | probed_matrix: this.grid 52 | }; 53 | } 54 | 55 | public isAvailable(): boolean { 56 | return this.isBedMesh; 57 | } 58 | } 59 | 60 | export default BedMeshObject; -------------------------------------------------------------------------------- /src/system/SystemInfo.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import CpuInfo, { ICpuInfo } from "./CpuInfo"; 3 | import SdInfo, { ISdInfo } from "./SdInfo"; 4 | import { IDistribution } from "./Distribution"; 5 | import Network, { TNetwork } from "./Network"; 6 | import Distribution from "./Distribution"; 7 | import ProcStats from "./ProcStats"; 8 | import MarlinRaker from "../MarlinRaker"; 9 | import ServiceManager from "./ServiceManager"; 10 | 11 | interface IMachineInfo { 12 | cpu_info: ICpuInfo; 13 | sd_info: ISdInfo | {}; 14 | distribution: IDistribution; 15 | network: TNetwork; 16 | } 17 | 18 | class SystemInfo { 19 | 20 | public readonly machineInfo: IMachineInfo; 21 | public readonly procStats: ProcStats; 22 | public readonly serviceManager: ServiceManager; 23 | 24 | public constructor(marlinRaker: MarlinRaker) { 25 | this.machineInfo = SystemInfo.loadMachineInfo(); 26 | this.procStats = new ProcStats(marlinRaker); 27 | this.serviceManager = new ServiceManager(marlinRaker); 28 | } 29 | 30 | private static loadMachineInfo(): IMachineInfo { 31 | return { 32 | cpu_info: CpuInfo.getCpuInfo(), 33 | sd_info: SdInfo.getSdInfo(), 34 | distribution: Distribution.getDistribution(), 35 | network: Network.getNetwork() 36 | }; 37 | } 38 | 39 | public static read(path: string): string { 40 | const file = fs.openSync(path, "r", 0o666); 41 | const chunks = []; 42 | let pos = 0; 43 | for (;;) { 44 | const buf = Buffer.allocUnsafe(1024); 45 | const read = fs.readSync(file, buf, 0, buf.length, pos); 46 | pos += read; 47 | if (!read) break; 48 | chunks.push(buf.subarray(0, read)); 49 | } 50 | return Buffer.concat(chunks).toString("utf-8"); 51 | } 52 | } 53 | 54 | export { IMachineInfo }; 55 | export default SystemInfo; -------------------------------------------------------------------------------- /config/printers/prusa_i3_mk3s.toml: -------------------------------------------------------------------------------- 1 | # Prusa i3 MK3S(+) 2 | 3 | [printer] 4 | bed_mesh = true 5 | print_volume = [210, 210, 250] 6 | 7 | [printer.extruder] 8 | min_temp = 0 9 | max_temp = 300 10 | min_extrude_temp = 180 11 | filament_diameter = 1.75 12 | 13 | [printer.heater_bed] 14 | min_temp = 0 15 | max_temp = 120 16 | 17 | [printer.gcode] 18 | send_m73 = false 19 | 20 | [macros.start_print] 21 | rename_existing = "start_base" 22 | gcode = """ 23 | ${ 24 | printer.printJob?.isReadyToPrint ? ` 25 | M118 E1 Printing ${printer.printJob.filename} ; display in console 26 | M117 Printing ${printer.printJob.filename} ; display on lcd 27 | M75 ${printer.printJob.filename} ; start print job timer 28 | start_base ; start print 29 | ` : "M118 E1 !! Cannot start print" 30 | } 31 | """ 32 | 33 | [macros.pause] 34 | rename_existing = "pause_base" 35 | gcode = """ 36 | ${ 37 | printer.printJob?.isPrinting ? ` 38 | pause_base ; pause print 39 | M601 ; park toolhead and pause print timer 40 | ` : "M118 E1 !! Not printing" 41 | } 42 | """ 43 | 44 | [macros.resume] 45 | rename_existing = "resume_base" 46 | gcode = """ 47 | ${ 48 | printer.pauseState ? ` 49 | M118 E1 Resuming ${printer.printJob.filename} ; display in console 50 | M117 Printing ${printer.printJob.filename} ; display on lcd 51 | M602 ; start print timer and move toolhead to last position 52 | resume_base ; resume printing 53 | ` : "M118 E1 !! Print is not paused" 54 | } 55 | """ 56 | 57 | [macros.cancel_print] 58 | rename_existing = "cancel_base" 59 | gcode = """ 60 | ${ 61 | printer.printJob?.isPrinting ? ` 62 | cancel_base ; cancel print 63 | M603 ; stop print job timer 64 | M118 E1 Print aborted ; display in console 65 | M117 Print aborted ; display on lcd 66 | ` : "M118 E1 !! Not printing" 67 | } 68 | """ 69 | 70 | [macros.sdcard_reset_file] 71 | rename_existing = "reset_base" 72 | gcode = """ 73 | reset_base ; reset selected print 74 | M117 ; clear lcd screen 75 | """ -------------------------------------------------------------------------------- /src/update/Updatable.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | import { logger } from "../Server"; 3 | import { spawn } from "child_process"; 4 | import readline from "readline"; 5 | import MarlinRaker from "../MarlinRaker"; 6 | 7 | type TLogger = (message: string, error?: boolean, complete?: boolean) => Promise; 8 | 9 | abstract class Updatable { 10 | 11 | public readonly name: string; 12 | public info?: TInfo; 13 | protected readonly marlinRaker: MarlinRaker; 14 | 15 | protected constructor(marlinRaker: MarlinRaker, name: string) { 16 | this.marlinRaker = marlinRaker; 17 | this.name = name; 18 | } 19 | 20 | public abstract checkForUpdate(): Promise; 21 | 22 | public abstract isUpdatePossible(): boolean; 23 | 24 | public abstract update(): Promise; 25 | 26 | protected createLogger(): TLogger { 27 | const procId = crypto.randomBytes(2).readUInt16LE(); 28 | return async (message: string, error = false, complete = false): Promise => { 29 | if (!complete && !message.trim()) return; 30 | logger[error ? "error" : "info"](`Updating ${this.name}: ${message}`); 31 | await this.marlinRaker.updateManager.notifyUpdateResponse(this.name, procId, message, complete); 32 | }; 33 | } 34 | 35 | protected async doUpdate(log: TLogger, command: string, args: string[]): Promise { 36 | 37 | const process = spawn(command, args, { shell: true }); 38 | const outLineReader = readline.createInterface(process.stdout); 39 | outLineReader.on("line", async (line) => { 40 | await log(line); 41 | }); 42 | const errLineReader = readline.createInterface(process.stderr); 43 | errLineReader.on("line", async (line) => { 44 | await log(line, true); 45 | }); 46 | 47 | await new Promise((resolve) => outLineReader.on("close", resolve)); 48 | await log("Update complete", false, true); 49 | } 50 | } 51 | 52 | export { Updatable }; -------------------------------------------------------------------------------- /src/system/CpuInfo.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "child_process"; 2 | import SystemInfo from "./SystemInfo"; 3 | import os from "os"; 4 | 5 | interface ICpuInfo { 6 | cpu_count: number; 7 | bits: string; 8 | processor: string; 9 | cpu_desc: string; 10 | serial_number: string; 11 | hardware_desc: string; 12 | model: string; 13 | total_memory: number; 14 | memory_units: string; 15 | } 16 | 17 | class CpuInfo { 18 | 19 | public static getCpuInfo(): ICpuInfo { 20 | const cpus = os.cpus(); 21 | const model = cpus[0].model; 22 | const cpuCount = cpus.length; 23 | let serial = "", hardware = "", cpuDesc = "", architecture = "", bits; 24 | 25 | try { 26 | const content = SystemInfo.read("/proc/cpuinfo"); 27 | for (const line of content.split(/\r?\n/)) { 28 | if (line.startsWith("Serial")) { 29 | serial = /^Serial\s+: 0*([0-9a-f]+)$/.exec(line)?.[1] ?? serial; 30 | } else if (line.startsWith("Hardware")) { 31 | hardware = /^Hardware\s+: (.+)$/.exec(line)?.[1] ?? hardware; 32 | } else if (line.startsWith("model name")) { 33 | cpuDesc = /^model name\s+: (.+)$/.exec(line)?.[1] ?? cpuDesc; 34 | } 35 | } 36 | 37 | bits = `${Number.parseInt(execSync("getconf LONG_BIT").toString("utf-8").trim()) || 32}bit`; 38 | architecture = execSync("uname -m").toString("utf-8").trim(); 39 | } catch (_) { 40 | bits = ["arm64", "ppc64", "x64", "s390x"].includes(os.arch()) ? "64bit" : "32bit"; 41 | } 42 | 43 | return { 44 | cpu_count: cpuCount, 45 | bits, 46 | processor: architecture, 47 | cpu_desc: cpuDesc, 48 | serial_number: serial, 49 | hardware_desc: hardware, 50 | model, 51 | total_memory: Math.round(os.totalmem() / 1000), 52 | memory_units: "kB" 53 | }; 54 | } 55 | } 56 | 57 | export { ICpuInfo }; 58 | export default CpuInfo; -------------------------------------------------------------------------------- /src/printer/objects/ToolheadObject.ts: -------------------------------------------------------------------------------- 1 | import PrinterObject from "./PrinterObject"; 2 | import { config } from "../../Server"; 3 | import { TVec3 } from "../../util/Utils"; 4 | import MarlinRaker from "../../MarlinRaker"; 5 | 6 | interface IObject { 7 | homed_axes: string; 8 | print_time: number; 9 | estimated_print_time: number; 10 | extruder: string; 11 | position: number[]; 12 | max_velocity?: number; 13 | max_accel?: number; 14 | max_accel_to_decel?: number; 15 | square_corner_velocity?: number; 16 | axis_minimum: number[]; 17 | axis_maximum: number[]; 18 | } 19 | 20 | class ToolheadObject extends PrinterObject { 21 | 22 | public readonly name = "toolhead"; 23 | private readonly marlinRaker: MarlinRaker; 24 | private readonly printVolume: TVec3; 25 | 26 | public constructor(marlinRaker: MarlinRaker) { 27 | super(); 28 | this.marlinRaker = marlinRaker; 29 | 30 | setInterval(() => { 31 | if (this.isAvailable()) this.emit(); 32 | }, 250); 33 | 34 | this.marlinRaker.on("stateChange", (state) => { 35 | if (state === "ready") { 36 | this.marlinRaker.printer?.on("homedAxesChange", this.emit.bind(this)); 37 | } 38 | }); 39 | 40 | this.printVolume = config.getGeneric("printer.print_volume", 41 | [220, 220, 240], (x): x is TVec3 => 42 | typeof x === "object" && Array.isArray(x) && x.length === 3 43 | ); 44 | } 45 | 46 | protected get(): IObject { 47 | return { 48 | homed_axes: this.marlinRaker.printer?.getHomedAxesString() ?? "", 49 | print_time: 0, 50 | estimated_print_time: 0, 51 | extruder: "extruder", 52 | position: this.marlinRaker.printer?.actualPosition ?? [0, 0, 0, 0], 53 | axis_minimum: [0, 0, 0, 0], 54 | axis_maximum: [...this.printVolume, 0] 55 | }; 56 | } 57 | 58 | public isAvailable(): boolean { 59 | return this.marlinRaker.state === "ready"; 60 | } 61 | } 62 | 63 | export default ToolheadObject; -------------------------------------------------------------------------------- /src/update/ScriptUpdatable.ts: -------------------------------------------------------------------------------- 1 | import { Updatable } from "./Updatable"; 2 | import { logger } from "../Server"; 3 | import { exec } from "child_process"; 4 | import Utils from "../util/Utils"; 5 | import MarlinRaker from "../MarlinRaker"; 6 | 7 | interface IInfo { 8 | version?: unknown; 9 | remote_version?: unknown; 10 | } 11 | 12 | class ScriptUpdatable extends Updatable { 13 | 14 | public readonly scriptFile: string; 15 | 16 | public constructor(marlinRaker: MarlinRaker, name: string, scriptFile: string) { 17 | super(marlinRaker, name); 18 | this.scriptFile = scriptFile; 19 | } 20 | 21 | public async checkForUpdate(): Promise { 22 | try { 23 | this.info = await new Promise((resolve, reject) => { 24 | exec(`${this.scriptFile} -i`, (error, stdout, stderr) => { 25 | if (error || stderr) { 26 | reject(error ?? stderr); 27 | return; 28 | } 29 | try { 30 | resolve(JSON.parse(stdout)); 31 | } catch (e) { 32 | reject(`${Utils.errorToString(e)}\nin ${stdout}`); 33 | } 34 | }); 35 | }); 36 | await MarlinRaker.getInstance().updateManager.emit(); 37 | } catch (e) { 38 | logger.error(`Error while checking for update for ${this.name}:`); 39 | logger.error(e); 40 | } 41 | } 42 | 43 | public isUpdatePossible(): boolean { 44 | return Boolean(this.info?.remote_version 45 | && this.info.remote_version !== "?" 46 | && this.info.remote_version !== this.info.version); 47 | } 48 | 49 | public async update(): Promise { 50 | if (!this.isUpdatePossible()) throw new Error("No update to download"); 51 | const log = this.createLogger(); 52 | await this.doUpdate(log, this.scriptFile, ["-u"]); 53 | await this.checkForUpdate(); 54 | await this.marlinRaker.updateManager.emit(); 55 | } 56 | } 57 | 58 | export default ScriptUpdatable; -------------------------------------------------------------------------------- /src/system/Distribution.ts: -------------------------------------------------------------------------------- 1 | import SystemInfo from "./SystemInfo"; 2 | import os from "os"; 3 | 4 | interface IDistribution { 5 | name: string; 6 | id: string; 7 | version: string; 8 | version_parts: { 9 | major: string; 10 | minor: string; 11 | build_numer: string; 12 | }; 13 | like: string; 14 | codename: string; 15 | } 16 | 17 | class Distribution { 18 | 19 | public static getDistribution(): IDistribution { 20 | 21 | let name = "Unknown", id = "", version = "", major = "", minor = "", buildNumber = "", like = "", codename = ""; 22 | 23 | switch (os.platform()) { 24 | case "win32": 25 | name = "Windows"; 26 | break; 27 | case "linux": 28 | name = "Linux"; 29 | break; 30 | case "darwin": 31 | name = "MacOS"; 32 | break; 33 | case "android": 34 | name = "Android"; 35 | break; 36 | } 37 | 38 | try { 39 | const content = SystemInfo.read("/etc/os-release"); 40 | const info: Record = Object.fromEntries(content.split(/\r?\n/) 41 | .map((line) => /^(.*)="?(.*?)(?:"$|$)/.exec(line)?.slice(1)) 42 | .filter((entry): entry is [string, string] => entry !== undefined && entry.length === 2)); 43 | 44 | name = info.PRETTY_NAME ?? info.NAME ?? name; 45 | id = info.ID ?? id; 46 | version = info.VERSION_ID ?? version; 47 | [major, minor, buildNumber] = version.split(".").concat("", "", ""); 48 | like = info.ID_LIKE ?? like; 49 | codename = info.VERSION_CODENAME ?? codename; 50 | } catch (_) { 51 | // 52 | } 53 | 54 | return { 55 | name, 56 | id, 57 | version, 58 | version_parts: { 59 | major, 60 | minor, 61 | build_numer: buildNumber 62 | }, 63 | like, 64 | codename 65 | }; 66 | } 67 | } 68 | 69 | export { IDistribution }; 70 | export default Distribution; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "marlinraker", 3 | "version": "v0.2.3-alpha", 4 | "main": "src/Server.ts", 5 | "scripts": { 6 | "dev": "cross-env NODE_ENV=development MARLINRAKER_DIR=./marlinraker_files/ ts-node --files src/Server.ts --serve-static --extended-logs", 7 | "dev.api": "cross-env NODE_ENV=development MARLINRAKER_DIR=./marlinraker_files/ ts-node --files src/Server.ts --extended-logs", 8 | "build": "node scripts/build.js", 9 | "build.zip": "node scripts/build.js --zip", 10 | "lint": "eslint src --max-warnings=0 --fix", 11 | "test": "eslint src --max-warnings=0", 12 | "docs.serve": "mkdocs serve" 13 | }, 14 | "author": "pauhull", 15 | "license": "GPLv3", 16 | "dependencies": { 17 | "@iarna/toml": "^2.2.5", 18 | "@stroncium/procfs": "^1.2.1", 19 | "chalk": "^4.1.2", 20 | "cors": "^2.8.5", 21 | "diskusage": "^1.1.3", 22 | "express": "^4.18.2", 23 | "fs-extra": "^10.1.0", 24 | "logrotate-stream": "^0.2.9", 25 | "multer": "^1.4.5-lts.1", 26 | "node-json-db": "^2.1.4", 27 | "rfc4648": "^1.5.2", 28 | "serialport": "^10.5.0", 29 | "source-map-support": "^0.5.21", 30 | "tslib": "^2.5.0", 31 | "unzipper": "^0.10.11", 32 | "ws": "^8.12.1" 33 | }, 34 | "devDependencies": { 35 | "@types/cors": "^2.8.13", 36 | "@types/express": "^4.17.17", 37 | "@types/fs-extra": "^9.0.13", 38 | "@types/logrotate-stream": "^0.2.31", 39 | "@types/multer": "^1.4.7", 40 | "@types/node": "^18.14.1", 41 | "@types/source-map-support": "^0.5.6", 42 | "@types/stroncium__procfs": "^1.2.0", 43 | "@types/unzipper": "^0.10.5", 44 | "@types/ws": "^8.5.4", 45 | "@typescript-eslint/eslint-plugin": "^5.53.0", 46 | "@typescript-eslint/parser": "^5.53.0", 47 | "cross-env": "^7.0.3", 48 | "esbuild": "^0.15.18", 49 | "esbuild-node-externals": "^1.6.0", 50 | "eslint": "^8.34.0", 51 | "execa": "^6.1.0", 52 | "listr2": "^5.0.7", 53 | "pre-commit": "^1.2.2", 54 | "ts-node": "^10.9.1", 55 | "typescript": "^4.9.5", 56 | "zip-local": "^0.3.5" 57 | }, 58 | "optionalDependencies": { 59 | "bufferutil": "^4.0.7", 60 | "utf-8-validate": "^5.0.10" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/system/VcgenCmd.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import { exec } from "child_process"; 3 | import SimpleNotification from "../api/notifications/SimpleNotification"; 4 | import MarlinRaker from "../MarlinRaker"; 5 | 6 | interface IThrottledState { 7 | bits: number; 8 | flags: string[]; 9 | } 10 | 11 | class VcgenCmd { 12 | 13 | public readonly throttledState: IThrottledState; 14 | public readonly isActive: boolean; 15 | private readonly marlinRaker: MarlinRaker; 16 | 17 | public constructor(marlinRaker: MarlinRaker) { 18 | this.marlinRaker = marlinRaker; 19 | this.throttledState = { 20 | bits: 0, 21 | flags: [] 22 | }; 23 | 24 | this.isActive = fs.existsSync("/usr/bin/vcgencmd"); 25 | if (this.isActive) { 26 | this.updateThrottledState(); 27 | setInterval(this.updateThrottledState.bind(this), 10000); 28 | } 29 | } 30 | 31 | private updateThrottledState(): void { 32 | exec("vcgencmd get_throttled", (error, stdout) => { 33 | if (error) return; 34 | const bits = Number.parseInt(stdout.trim().substring(12), 16) || 0; 35 | const flags = []; 36 | if (bits & 1) flags.push("Under-Voltage Detected"); 37 | if (bits & 1 << 1) flags.push("Frequency Capped"); 38 | if (bits & 1 << 2) flags.push("Currently Throttled"); 39 | if (bits & 1 << 3) flags.push("Temperature Limit Active"); 40 | if (bits & 1 << 16) flags.push("Previously Under-Volted"); 41 | if (bits & 1 << 17) flags.push("Previously Frequency Capped"); 42 | if (bits & 1 << 18) flags.push("Previously Throttled"); 43 | if (bits & 1 << 19) flags.push("Previously Temperature Limited"); 44 | if (this.throttledState.bits !== bits) { 45 | this.throttledState.bits = bits; 46 | this.throttledState.flags = flags; 47 | void this.marlinRaker.socketHandler.broadcast(new SimpleNotification("notify_cpu_throttled", [this.throttledState])); 48 | } 49 | }); 50 | } 51 | } 52 | 53 | export { IThrottledState }; 54 | export default VcgenCmd; -------------------------------------------------------------------------------- /config/printers/prusa_mini.toml: -------------------------------------------------------------------------------- 1 | # Prusa MINI(+) 2 | 3 | [printer] 4 | bed_mesh = true 5 | print_volume = [180, 180, 180] 6 | 7 | [printer.extruder] 8 | min_temp = 0 9 | max_temp = 280 10 | min_extrude_temp = 180 11 | filament_diameter = 1.75 12 | 13 | [printer.heater_bed] 14 | min_temp = 0 15 | max_temp = 100 16 | 17 | [printer.gcode] 18 | send_m73 = false 19 | 20 | [macros.start_print] 21 | rename_existing = "start_base" 22 | gcode = """ 23 | ${ 24 | printer.printJob?.isReadyToPrint ? ` 25 | M118 E1 Printing ${printer.printJob.filename} ; display in console 26 | start_base ; start print 27 | ` : "M118 E1 !! Cannot start print" 28 | } 29 | """ 30 | 31 | [macros.pause] 32 | rename_existing = "pause_base" 33 | gcode = """ 34 | ${ 35 | printer.printJob?.isPrinting ? ` 36 | M108 ; cancel heating 37 | pause_base ; pause print 38 | M118 E1 Paused print ; display in console 39 | G91 40 | G1 Z5 E-20 F600 ; move nozzle up 5 mm and retract 20 mm 41 | G90 42 | G1 X0 Y0 F6000 ; move to 0,0 43 | ` : "M118 E1 !! Not printing" 44 | } 45 | """ 46 | 47 | [macros.resume] 48 | rename_existing = "resume_base" 49 | gcode = """ 50 | ${ 51 | printer.pauseState ? ` 52 | M118 E1 Resuming ${printer.printJob.filename} ; display in console 53 | G90 ; set absolute positioning 54 | G1 X${printer.pauseState.x} Y${printer.pauseState.y} F6000 ; move to last print position 55 | G91 56 | G1 Z-5 E20 F600 ; move nozzle down 5 mm and extrude 20 mm 57 | ${printer.pauseState.isAbsolute ? "G90" : "G91"} ; reset positioning mode 58 | ${printer.pauseState.isAbsoluteE ? "M82" : "M83"} ; reset extruder positioning mode 59 | G0 F${printer.pauseState.feedrate} ; reset feedrate 60 | resume_base ; resume printing 61 | ` : "M118 E1 !! Print is not paused" 62 | } 63 | """ 64 | 65 | [macros.cancel_print] 66 | rename_existing = "cancel_base" 67 | gcode = """ 68 | ${ 69 | printer.printJob?.isPrinting ? ` 70 | M108 ; cancel heating 71 | cancel_base ; cancel print 72 | M118 E1 Print aborted ; display in console 73 | G28 XY ; home x and y 74 | M18 ; turn off steppers 75 | M104 S0 ; turn off hotend 76 | M140 S0 ; turn off heated bed 77 | M107 ; turn off print fan 78 | ` : "M118 E1 !! Not printing" 79 | } 80 | """ -------------------------------------------------------------------------------- /src/files/LineReader.ts: -------------------------------------------------------------------------------- 1 | import { ReadStream } from "fs"; 2 | import readline, { Interface } from "readline"; 3 | 4 | class LineReader { 5 | 6 | private static readonly BUFFER_CAP = 500; 7 | 8 | public position: number; 9 | private readonly reader: Interface; 10 | private readonly buffer: string[]; 11 | private paused: boolean; 12 | private closed: boolean; 13 | 14 | public constructor(stream: ReadStream) { 15 | this.buffer = []; 16 | this.position = 0; 17 | this.paused = false; 18 | this.closed = false; 19 | 20 | this.reader = readline.createInterface(stream); 21 | this.reader.prependListener("line", this.handleLine.bind(this)); 22 | this.reader.on("pause", () => this.paused = true); 23 | this.reader.on("resume", () => this.paused = false); 24 | this.reader.on("close", () => this.closed = true); 25 | } 26 | 27 | public close(): void { 28 | this.reader.close(); 29 | } 30 | 31 | public async readLine(): Promise { 32 | if (this.closed && !this.buffer.length) return null; 33 | if (this.paused && this.buffer.length < LineReader.BUFFER_CAP) { 34 | this.reader.resume(); 35 | } 36 | if (this.buffer.length) { 37 | const line = this.buffer.shift()!; 38 | this.position += Buffer.byteLength(line, "utf-8") + 1; // \n 39 | return line; 40 | } else { 41 | return new Promise((resolve) => { 42 | const onLine = async (): Promise => { 43 | resolve(await this.readLine()); 44 | this.reader.removeListener("close", onClose); 45 | }; 46 | const onClose = (): void => { 47 | resolve(null); 48 | this.reader.removeListener("line", onLine); 49 | }; 50 | this.reader.once("line", onLine); 51 | this.reader.once("close", onClose); 52 | }); 53 | } 54 | } 55 | 56 | private handleLine(line: string): void { 57 | this.buffer.push(line); 58 | if (this.buffer.length >= LineReader.BUFFER_CAP) { 59 | this.reader.pause(); 60 | } 61 | } 62 | } 63 | 64 | export default LineReader; -------------------------------------------------------------------------------- /src/api/MessageHandler.ts: -------------------------------------------------------------------------------- 1 | import Response from "./response/Response"; 2 | import ErrorResponse from "./response/ErrorResponse"; 3 | import ResultResponse from "./response/ResultResponse"; 4 | import { IMethodExecutor, TSender } from "./executors/IMethodExecutor"; 5 | import { logger } from "../Server"; 6 | 7 | abstract class MessageHandler { 8 | 9 | protected async handleMessage(sender: TSender, executor: IMethodExecutor | undefined, params: unknown | undefined): Promise { 10 | if (!executor) { 11 | return new ErrorResponse(404, "Method not found"); 12 | } 13 | let timeout: NodeJS.Timer | undefined; 14 | return await Promise.race([ 15 | 16 | new Promise((resolve) => { 17 | if (executor.timeout === null) return; 18 | const ms = executor.timeout ?? 10000; 19 | timeout = setTimeout(() => { 20 | logger.error(`${executor.name} timed out after ${ms / 1000}s`); 21 | resolve(new ErrorResponse(408, "Request timeout")); 22 | }, ms); 23 | }), 24 | 25 | new Promise((resolve) => { 26 | const handleError = (e: unknown): void => { 27 | clearTimeout(timeout); 28 | const code = (e as { code?: number }).code ?? 500; 29 | const message = (e as { message?: string }).message ?? e?.toString(); 30 | logger.error(`Error in ${executor.name}: ${message}`); 31 | resolve(new ErrorResponse(code, `Method error: ${e}`)); 32 | }; 33 | 34 | try { 35 | Promise.resolve(executor.invoke(sender, (params ?? {}) as Partial)).then((response) => { 36 | clearTimeout(timeout); 37 | if (response === null) { 38 | logger.error(`Error in ${executor.name}: No response`); 39 | resolve(new ErrorResponse(500, "No response")); 40 | } 41 | resolve(new ResultResponse(response)); 42 | }).catch(handleError); 43 | } catch (e) { 44 | handleError(e); 45 | } 46 | }) 47 | ]); 48 | } 49 | } 50 | 51 | export default MessageHandler; -------------------------------------------------------------------------------- /src/update/HttpsRequest.ts: -------------------------------------------------------------------------------- 1 | import unzip from "unzipper"; 2 | import * as https from "https"; 3 | 4 | class HttpsRequest { 5 | 6 | private static readonly USER_AGENT = "Mozilla/5.0 (Windows NT 6.2; rv:20.0) Gecko/20121202 Firefox/20.0"; 7 | private readonly url: string; 8 | 9 | public constructor(url: string) { 10 | this.url = url; 11 | } 12 | 13 | public async getString(): Promise { 14 | return new Promise((resolve, reject) => { 15 | https.get(this.url, { headers: { "User-Agent": HttpsRequest.USER_AGENT } }, (res) => { 16 | const chunks: Buffer[] = []; 17 | res.on("error", reject); 18 | res.on("data", (buf) => chunks.push(buf)); 19 | res.on("end", () => { 20 | const result = Buffer.concat(chunks).toString("utf-8"); 21 | resolve(result); 22 | }); 23 | }); 24 | }); 25 | } 26 | 27 | public async unzipTo(path: string, onProgress?: (progress: number, size: number) => void, onComplete?: () => void): Promise { 28 | return new Promise((resolve, reject) => { 29 | https.get(this.url, { headers: { "User-Agent": HttpsRequest.USER_AGENT } }, (res) => { 30 | if (res.statusCode === 302 && res.headers.location) { 31 | new HttpsRequest(res.headers.location).unzipTo(path, onProgress, onComplete).then(resolve).catch(reject); 32 | return; 33 | } 34 | 35 | let progress = 0; 36 | const size = Number.parseInt(res.headers["content-length"] ?? "0"); 37 | 38 | const timer = setInterval(() => { 39 | onProgress?.(progress, size); 40 | }, 500); 41 | 42 | const outStream = unzip.Extract({ path }); 43 | outStream.on("error", reject); 44 | res.on("error", reject); 45 | 46 | res.on("data", (buf) => { 47 | progress += buf.length; 48 | outStream.write(buf); 49 | }); 50 | 51 | res.on("end", () => { 52 | clearInterval(timer); 53 | onComplete?.(); 54 | }); 55 | 56 | outStream.on("close", () => { 57 | resolve(); 58 | }); 59 | }); 60 | }); 61 | } 62 | } 63 | 64 | export default HttpsRequest; -------------------------------------------------------------------------------- /src/printer/objects/PrinterObject.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | 3 | interface ISubscriber { 4 | subscriber: () => void; 5 | previous: Partial; 6 | } 7 | 8 | abstract class PrinterObject { 9 | 10 | public abstract readonly name: string; 11 | protected subscribers: ISubscriber[]; 12 | 13 | protected constructor() { 14 | this.subscribers = []; 15 | } 16 | 17 | public getFull(subscriber: () => void, topics: string[] | null): Partial { 18 | const response = this.query(topics); 19 | const subscriberObject = this.subscribers.find((s) => s.subscriber === subscriber); 20 | if (subscriberObject) { 21 | subscriberObject.previous = response; 22 | } 23 | return response; 24 | } 25 | 26 | public getDifference(subscriber: () => void, topics: string[] | null): Partial { 27 | const subscriberObject = this.subscribers.find((s) => s.subscriber === subscriber); 28 | const previous: Partial = subscriberObject?.previous ?? {}; 29 | const now = Object.freeze(this.query(topics)); 30 | const diff: Partial = {}; 31 | for (const key in now) { 32 | try { 33 | assert.deepEqual(previous[key], now[key]); 34 | } catch (_) { 35 | diff[key] = now[key]; 36 | } 37 | } 38 | if (subscriberObject && Object.keys(diff).length) { 39 | subscriberObject.previous = now; 40 | } 41 | return diff; 42 | } 43 | 44 | public query(topics: string[] | null): Partial { 45 | const response = this.get(); 46 | let withTopics: Partial = {}; 47 | if (topics) { 48 | topics.forEach((topic) => withTopics[topic as keyof TResponse] = response[topic as keyof TResponse]); 49 | } else { 50 | withTopics = response; 51 | } 52 | return withTopics; 53 | } 54 | 55 | protected abstract get(): TResponse; 56 | 57 | public emit(): void { 58 | this.subscribers.forEach((s) => s.subscriber()); 59 | } 60 | 61 | public subscribe(subscriber: () => void): void { 62 | this.subscribers.push({ subscriber, previous: {} }); 63 | } 64 | 65 | public unsubscribe(subscriber: () => void): void { 66 | this.subscribers = this.subscribers.filter((s) => s.subscriber !== subscriber); 67 | } 68 | 69 | public isAvailable(): boolean { 70 | return true; 71 | } 72 | } 73 | 74 | export default PrinterObject; -------------------------------------------------------------------------------- /src/api/executors/ServerInfoExecutor.ts: -------------------------------------------------------------------------------- 1 | import { IMethodExecutor, TSender } from "./IMethodExecutor"; 2 | import { config, logger } from "../../Server"; 3 | import MarlinRaker, { TPrinterState } from "../../MarlinRaker"; 4 | import { Level } from "../../logger/Logger"; 5 | import packageJson from "../../../package.json"; 6 | 7 | interface IResult { 8 | klippy_connected: boolean; 9 | klippy_state: TPrinterState; 10 | components: string[]; 11 | failed_components: string[]; 12 | registered_directories: string[]; 13 | warnings: string[]; 14 | websocket_count: number; 15 | moonraker_version: string; 16 | api_version: number[]; 17 | api_version_string: string; 18 | type: string; 19 | } 20 | 21 | class ServerInfoExecutor implements IMethodExecutor { 22 | 23 | public readonly name = "server.info"; 24 | private readonly versionArray: number[]; 25 | private readonly marlinRaker: MarlinRaker; 26 | 27 | public constructor(marlinRaker: MarlinRaker) { 28 | this.marlinRaker = marlinRaker; 29 | this.versionArray = packageJson.version 30 | .replace(/[^0-9.]/g, "") 31 | .split(".") 32 | .map((s) => Number.parseInt(s)) 33 | .filter((n) => !Number.isNaN(n)); 34 | } 35 | 36 | public invoke(_: TSender, __: undefined): IResult { 37 | const warnings = config.warnings.slice(); 38 | if (logger.level > Level.info && process.env.NODE_ENV !== "development") { 39 | warnings.push("\"extended_logs\" is enabled. Only use this option for debugging purposes. This option can affect print performance."); 40 | } 41 | const components = [ 42 | "server", 43 | "file_manager", 44 | "machine", 45 | "database", 46 | "data_store", 47 | "proc_stats", 48 | "history" 49 | ]; 50 | if (this.marlinRaker.updateManager.updatables.size) { 51 | components.push("update_manager"); 52 | } 53 | 54 | return { 55 | klippy_connected: true, 56 | klippy_state: this.marlinRaker.state, 57 | components, 58 | failed_components: [], 59 | registered_directories: ["gcodes", "config"], 60 | warnings, 61 | websocket_count: this.marlinRaker.connectionManager.connections.length, 62 | moonraker_version: packageJson.version, 63 | api_version: this.versionArray, 64 | api_version_string: packageJson.version, 65 | type: "marlinraker" 66 | }; 67 | } 68 | } 69 | 70 | export default ServerInfoExecutor; 71 | -------------------------------------------------------------------------------- /src/printer/watchers/PositionWatcher.ts: -------------------------------------------------------------------------------- 1 | import Printer from "../Printer"; 2 | import ParserUtil from "../ParserUtil"; 3 | import Watcher from "./Watcher"; 4 | import MarlinRaker from "../../MarlinRaker"; 5 | 6 | class PositionWatcher extends Watcher { 7 | 8 | private readonly printer: Printer; 9 | private readonly autoReport: boolean; 10 | private readonly timer?: NodeJS.Timer; 11 | 12 | public constructor(printer: Printer, marlinRaker: MarlinRaker, reportVelocity: boolean) { 13 | super(); 14 | this.printer = printer; 15 | this.autoReport = !reportVelocity && (printer.capabilities.AUTOREPORT_POS ?? printer.capabilities.AUTOREPORT_POSITION ?? false); 16 | 17 | if (this.autoReport) { 18 | if (!this.printer.isPrusa) { 19 | void this.printer.queueGcode("M154 S1", false, false); 20 | } 21 | } else { 22 | let requested = false; 23 | this.timer = setInterval(async () => { 24 | if (requested || !reportVelocity && marlinRaker.jobManager.isPrinting()) return; 25 | requested = true; 26 | const response = await this.printer.queueGcode("M114 R", true, false); 27 | requested = false; 28 | this.readPosition(response); 29 | }, reportVelocity ? 200 : 1000); 30 | } 31 | 32 | if (reportVelocity) { 33 | let last = 0; 34 | printer.on("actualPositionChange", (oldPos, newPos) => { 35 | const now = Date.now() / 1000; 36 | if (last) { 37 | const dxy = Math.sqrt((newPos[0] - oldPos[0]) ** 2 + (newPos[1] - oldPos[1]) ** 2); 38 | this.printer.actualVelocity = dxy / (now - last); 39 | const de = newPos[3] - oldPos[3]; 40 | this.printer.actualExtruderVelocity = de / (now - last); 41 | } 42 | last = now; 43 | }); 44 | } 45 | } 46 | 47 | public handle(line: string): boolean { 48 | if (!line.startsWith("X:")) return false; 49 | this.readPosition(line); 50 | return true; 51 | } 52 | 53 | private readPosition(data: string): void { 54 | const actualPos = ParserUtil.parseM114Response(data); 55 | if (!actualPos) return; 56 | const oldPos = this.printer.actualPosition; 57 | this.printer.actualPosition = actualPos; 58 | this.printer.emit("actualPositionChange", oldPos, actualPos); 59 | super.onLoaded(); 60 | } 61 | 62 | public cleanup(): void { 63 | clearInterval(this.timer); 64 | } 65 | } 66 | 67 | export default PositionWatcher; -------------------------------------------------------------------------------- /config/printers/generic.toml: -------------------------------------------------------------------------------- 1 | # Generic 3D Printer 2 | 3 | [printer] 4 | bed_mesh = false 5 | print_volume = [220, 220, 240] 6 | 7 | [printer.extruder] 8 | min_temp = 0 9 | max_temp = 250 10 | min_extrude_temp = 180 11 | filament_diameter = 1.75 12 | 13 | [printer.heater_bed] 14 | min_temp = 0 15 | max_temp = 100 16 | 17 | [printer.gcode] 18 | send_m73 = true 19 | 20 | [macros.start_print] 21 | rename_existing = "start_base" 22 | gcode = """ 23 | ${ 24 | printer.printJob?.isReadyToPrint ? ` 25 | M118 E1 Printing ${printer.printJob.filename} ; display in console 26 | M117 Printing ${printer.printJob.filename} ; display on lcd 27 | M75 ${printer.printJob.filename} ; start print job timer 28 | start_base ; start print 29 | ` : "M118 E1 !! Cannot start print" 30 | } 31 | """ 32 | 33 | [macros.pause] 34 | rename_existing = "pause_base" 35 | gcode = """ 36 | ${ 37 | printer.printJob?.isPrinting ? ` 38 | M108 ; cancel heating 39 | pause_base ; pause print 40 | M76 ; pause print job timer 41 | M118 E1 Paused print ; display in console 42 | M117 Print paused ; display on lcd 43 | G91 44 | G1 Z5 E-20 F600 ; move nozzle up 5 mm and retract 20 mm 45 | G90 46 | G1 X0 Y0 F6000 ; move to 0,0 47 | ` : "M118 E1 !! Not printing" 48 | } 49 | """ 50 | 51 | [macros.resume] 52 | rename_existing = "resume_base" 53 | gcode = """ 54 | ${ 55 | printer.pauseState ? ` 56 | M118 E1 Resuming ${printer.printJob.filename} ; display in console 57 | M117 Printing ${printer.printJob.filename} ; display on lcd 58 | M75 ; resume print job timer 59 | G90 ; set absolute positioning 60 | G1 X${printer.pauseState.x} Y${printer.pauseState.y} F6000 ; move to last print position 61 | G91 62 | G1 Z-5 E20 F600 ; move nozzle down 5 mm and extrude 20 mm 63 | ${printer.pauseState.isAbsolute ? "G90" : "G91"} ; reset positioning mode 64 | ${printer.pauseState.isAbsoluteE ? "M82" : "M83"} ; reset extruder positioning mode 65 | G0 F${printer.pauseState.feedrate} ; reset feedrate 66 | resume_base ; resume printing 67 | ` : "M118 E1 !! Print is not paused" 68 | } 69 | """ 70 | 71 | [macros.cancel_print] 72 | rename_existing = "cancel_base" 73 | gcode = """ 74 | ${ 75 | printer.printJob?.isPrinting ? ` 76 | M108 ; cancel heating 77 | cancel_base ; cancel print 78 | M77 ; stop print job timer 79 | M118 E1 Print aborted ; display in console 80 | M117 Print aborted ; display on lcd 81 | G28 XY ; home x and y 82 | M18 ; turn off steppers 83 | M104 S0 ; turn off hotend 84 | M140 S0 ; turn off heated bed 85 | M107 ; turn off print fan 86 | ` : "M118 E1 !! Not printing" 87 | } 88 | """ 89 | 90 | [macros.sdcard_reset_file] 91 | rename_existing = "reset_base" 92 | gcode = """ 93 | reset_base ; reset selected print 94 | M117 ; clear lcd screen 95 | """ -------------------------------------------------------------------------------- /src/printer/macros/CustomMacro.ts: -------------------------------------------------------------------------------- 1 | import { IMacro } from "./IMacro"; 2 | import { logger } from "../../Server"; 3 | import SimpleNotification from "../../api/notifications/SimpleNotification"; 4 | import Utils from "../../util/Utils"; 5 | import path from "path"; 6 | import MarlinRaker from "../../MarlinRaker"; 7 | 8 | type TGcodeEvaluator = (args: Record, printer: unknown) => string; 9 | 10 | class CustomMacro implements IMacro { 11 | 12 | public readonly name: string; 13 | public readonly evaluate: TGcodeEvaluator; 14 | 15 | public constructor(name: string, evaluate: TGcodeEvaluator) { 16 | this.name = name; 17 | this.evaluate = evaluate; 18 | } 19 | 20 | public async execute(args: Record): Promise { 21 | const marlinRaker = MarlinRaker.getInstance(); 22 | const printer = marlinRaker.printer; 23 | if (!printer) return; 24 | 25 | const printJob = marlinRaker.jobManager.currentPrintJob; 26 | const printJobObj = printJob && { 27 | state: marlinRaker.jobManager.state, 28 | filepath: printJob.filename, 29 | filename: path.basename(printJob.filename), 30 | filePosition: printJob.filePosition, 31 | progress: printJob.progress, 32 | isPrinting: marlinRaker.jobManager.isPrinting(), 33 | isReadyToPrint: marlinRaker.jobManager.isReadyToPrint() 34 | }; 35 | 36 | const printerObject = Object.freeze({ 37 | state: marlinRaker.state, 38 | stateMessage: marlinRaker.stateMessage, 39 | x: printer.actualPosition[0], 40 | y: printer.actualPosition[1], 41 | z: printer.actualPosition[2], 42 | e: printer.actualPosition[3], 43 | pauseState: printer.pauseState, 44 | hasEmergencyParser: printer.hasEmergencyParser, 45 | speedFactor: printer.speedFactor, 46 | extrudeFactor: printer.extrudeFactor, 47 | fanSpeed: printer.fanSpeed, 48 | capabilities: printer.capabilities, 49 | isAbsolute: printer.isAbsolutePositioning, 50 | isAbsoluteE: printer.isAbsoluteEPositioning, 51 | feedrate: printer.feedrate, 52 | info: printer.info, 53 | isM73Supported: printer.isM73Supported, 54 | isPrusa: printer.isPrusa, 55 | printJob: printJobObj 56 | }); 57 | 58 | try { 59 | const gcode = this.evaluate(args, printerObject); 60 | await marlinRaker.dispatchCommand(gcode, false); 61 | } catch (e) { 62 | logger.error(`Cannot evaluate gcode macro "${this.name}":`); 63 | logger.error(e); 64 | const errorStr = `!! Error on '${this.name}': ${Utils.errorToString(e)}`; 65 | await marlinRaker.socketHandler.broadcast(new SimpleNotification("notify_gcode_response", [errorStr])); 66 | } 67 | } 68 | } 69 | 70 | export { TGcodeEvaluator }; 71 | export default CustomMacro; -------------------------------------------------------------------------------- /src/update/SystemUpdatable.ts: -------------------------------------------------------------------------------- 1 | import { Updatable } from "./Updatable"; 2 | import { spawn } from "child_process"; 3 | import readline from "readline"; 4 | import { logger } from "../Server"; 5 | import MarlinRaker from "../MarlinRaker"; 6 | 7 | interface IInfo { 8 | package_count: number; 9 | package_list: string[]; 10 | } 11 | 12 | class SystemUpdatable extends Updatable { 13 | 14 | private packages: string[]; 15 | 16 | public constructor(marlinRaker: MarlinRaker) { 17 | super(marlinRaker, "system"); 18 | this.packages = []; 19 | } 20 | 21 | public async checkForUpdate(): Promise { 22 | if (process.platform !== "linux") return; 23 | return new Promise((resolve) => { 24 | let process; 25 | try { 26 | process = spawn("sudo", ["apt", "update"]); 27 | } catch (e) { 28 | logger.warn("Could not execute 'sudo apt update'"); 29 | logger.debug(e); 30 | resolve(); 31 | return; 32 | } 33 | 34 | process.on("exit", (code: number) => { 35 | if (code !== 0) { 36 | logger.warn(`'sudo apt update' exited with error code ${code}`); 37 | resolve(); 38 | return; 39 | } 40 | 41 | const proc = spawn("apt", ["list", "--upgradable"], { shell: true }); 42 | const reader = readline.createInterface(proc.stdout); 43 | const newPackages: string[] = []; 44 | 45 | reader.on("line", (line) => { 46 | if (line === "Listing...") return; 47 | const packageName = line.split("/")[0]; 48 | newPackages.push(packageName); 49 | }); 50 | 51 | reader.on("close", async () => { 52 | this.packages = newPackages; 53 | this.updateInfo(); 54 | await this.marlinRaker.updateManager.emit(); 55 | logger.info(`${this.packages.length} packages can be upgraded`); 56 | resolve(); 57 | }); 58 | }); 59 | }); 60 | } 61 | 62 | public isUpdatePossible(): boolean { 63 | return this.packages.length > 0; 64 | } 65 | 66 | public async update(): Promise { 67 | if (!this.isUpdatePossible()) throw new Error("No packages to upgrade"); 68 | const log = this.createLogger(); 69 | await log(`Upgrading ${this.packages.length} packages`); 70 | await this.doUpdate(log, "sudo", ["apt", "upgrade", "-y"]); 71 | this.packages = []; 72 | this.updateInfo(); 73 | await this.marlinRaker.updateManager.emit(); 74 | } 75 | 76 | private updateInfo(): void { 77 | this.info = { 78 | package_count: this.packages.length, 79 | package_list: this.packages 80 | }; 81 | } 82 | } 83 | 84 | export default SystemUpdatable; -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/strict" 5 | ], 6 | "parser": "@typescript-eslint/parser", 7 | "plugins": ["@typescript-eslint"], 8 | "parserOptions": { 9 | "project": "./tsconfig.json", 10 | "tsconfigRootDir": "./" 11 | }, 12 | "root": true, 13 | "rules": { 14 | "no-unused-vars": "off", 15 | "@typescript-eslint/no-unused-vars": [ 16 | "warn", 17 | { 18 | "argsIgnorePattern": "^_", 19 | "varsIgnorePattern": "^_", 20 | "caughtErrorsIgnorePattern": "^_" 21 | } 22 | ], 23 | "@typescript-eslint/no-non-null-assertion": "off", 24 | "quotes": ["warn", "double"], 25 | "semi": ["warn", "always"], 26 | "indent": ["warn", 4], 27 | "@typescript-eslint/brace-style": "warn", 28 | "@typescript-eslint/comma-dangle": "warn", 29 | "@typescript-eslint/comma-spacing": "warn", 30 | "@typescript-eslint/consistent-indexed-object-style": "warn", 31 | "@typescript-eslint/explicit-function-return-type": "warn", 32 | "@typescript-eslint/explicit-member-accessibility": "warn", 33 | "@typescript-eslint/func-call-spacing": "warn", 34 | "@typescript-eslint/keyword-spacing": "warn", 35 | "@typescript-eslint/no-extraneous-class": "off", 36 | "@typescript-eslint/no-base-to-string": "off", 37 | "@typescript-eslint/no-extra-parens": "warn", 38 | "@typescript-eslint/no-extra-semi": "warn", 39 | "@typescript-eslint/no-require-imports": "warn", 40 | "@typescript-eslint/no-shadow": "warn", 41 | "@typescript-eslint/no-unnecessary-condition": "warn", 42 | "@typescript-eslint/no-unnecessary-qualifier": "warn", 43 | "@typescript-eslint/no-unsafe-declaration-merging": "off", 44 | "@typescript-eslint/object-curly-spacing": [ 45 | "warn", 46 | "always" 47 | ], 48 | "@typescript-eslint/prefer-for-of": "warn", 49 | "@typescript-eslint/member-delimiter-style": "warn", 50 | "@typescript-eslint/no-floating-promises": ["error", 51 | { 52 | "ignoreVoid": true 53 | }], 54 | "@typescript-eslint/no-namespace": "off", 55 | "@typescript-eslint/prefer-nullish-coalescing": "off", 56 | "@typescript-eslint/prefer-readonly": "warn", 57 | "@typescript-eslint/promise-function-async": "warn", 58 | "@typescript-eslint/quotes": "warn", 59 | "@typescript-eslint/semi": "warn", 60 | "@typescript-eslint/space-infix-ops": "warn", 61 | "@typescript-eslint/type-annotation-spacing": "warn", 62 | "array-bracket-spacing": "warn", 63 | "arrow-parens": "warn", 64 | "arrow-spacing": "warn", 65 | "block-spacing": "warn", 66 | "dot-location": [ 67 | "warn", 68 | "property" 69 | ], 70 | "eqeqeq": "warn", 71 | "key-spacing": "warn", 72 | "no-confusing-arrow": "warn", 73 | "no-console": "error", 74 | "no-implicit-coercion": "warn", 75 | "no-lonely-if": "warn", 76 | "no-multi-spaces": "warn", 77 | "no-trailing-spaces": "warn", 78 | "no-whitespace-before-property": "warn", 79 | "operator-linebreak": [ 80 | "warn", 81 | "before" 82 | ], 83 | "prefer-exponentiation-operator": "warn", 84 | "rest-spread-spacing": "warn", 85 | "space-before-blocks": "warn", 86 | "space-in-parens": "warn", 87 | "space-unary-ops": "warn", 88 | "spaced-comment": "warn" 89 | } 90 | } -------------------------------------------------------------------------------- /src/system/SdInfo.ts: -------------------------------------------------------------------------------- 1 | import SystemInfo from "./SystemInfo"; 2 | import { Buffer } from "buffer"; 3 | 4 | interface ISdInfo { 5 | manufacturer_id: string; 6 | manufacturer: string; 7 | oem_id: string; 8 | product_name: string; 9 | product_revision: string; 10 | serial_number: string; 11 | manufacturer_date: string; 12 | capacity: string; 13 | total_bytes: number; 14 | } 15 | 16 | class SdInfo { 17 | 18 | private static readonly MANUFACTURERS: Record = { 19 | "1b": "Samsung", 20 | "03": "Sandisk", 21 | "74": "PNY" 22 | }; 23 | 24 | public static getSdInfo(): ISdInfo | {} { 25 | 26 | try { 27 | const cid = SystemInfo.read("/sys/block/mmcblk0/device/cid").trim().toLowerCase(); 28 | const manufacturerId = cid.substring(0, 2); 29 | const manufacturer = SdInfo.MANUFACTURERS[manufacturerId] ?? "Unknown"; 30 | const oemId = cid.substring(2, 6); 31 | const productName = Buffer.from(cid.substring(6, 16), "hex").toString("ascii"); 32 | const productRevision = `${Number.parseInt(cid[16], 16)}.${Number.parseInt(cid[17], 16)}`; 33 | const serial = cid.substring(18, 26); 34 | const year = Number.parseInt(cid.substring(27, 29), 16) + 2000; 35 | const month = Number.parseInt(cid[29], 16); 36 | const manufacturerDate = `${month}/${year}`; 37 | 38 | let capacity = "Unknown", totalBytes = 0; 39 | 40 | const csd = Buffer.from(SystemInfo.read("/sys/block/mmcblk0/device/csd").trim().toLowerCase(), "hex"); 41 | switch (csd[0] >> 6 & 0x3) { 42 | case 0: { 43 | const maxBlockLen = (csd[5] & 0xf) ** 2; 44 | const cSize = (csd[6] & 0x3) << 10 | csd[7] << 2 | csd[8] >> 6 & 0x3; 45 | const cMultReg = (csd[9] & 0x3) << 1 | csd[10] >> 7; 46 | const cMult = (cMultReg + 2) ** 2; 47 | totalBytes = (cSize + 1) * cMult * maxBlockLen; 48 | capacity = `${Math.round(totalBytes / 1024 ** 2 * 10) / 10} MiB`; 49 | break; 50 | } 51 | case 1: { 52 | const cSize = (csd[7] & 0x3f) << 16 | csd[8] << 8 | csd[9]; 53 | totalBytes = (cSize + 1) * 512 * 1024; 54 | capacity = `${Math.round(totalBytes / 1024 ** 3 * 10) / 10} GiB`; 55 | break; 56 | } 57 | case 2: { 58 | const cSize = (csd[6] & 0xf) << 24 | csd[7] << 16 | csd[8] << 8 | csd[9]; 59 | totalBytes = (cSize + 1) * 512 * 1024; 60 | capacity = `${Math.round(totalBytes / 1024 ** 4 / 10)} TiB`; 61 | } 62 | } 63 | 64 | return { 65 | manufacturer_id: manufacturerId, 66 | manufacturer: manufacturer, 67 | oem_id: oemId, 68 | product_name: productName, 69 | product_revision: productRevision, 70 | serial_number: serial, 71 | manufacturer_date: manufacturerDate, 72 | capacity, 73 | total_bytes: totalBytes 74 | }; 75 | } catch (_) { 76 | return {}; 77 | } 78 | } 79 | } 80 | 81 | export { ISdInfo }; 82 | export default SdInfo; -------------------------------------------------------------------------------- /src/logger/Logger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import chalk from "chalk"; 3 | import logrotateStream from "logrotate-stream"; 4 | import fs, { WriteStream } from "fs-extra"; 5 | import path from "path"; 6 | import { Writable } from "stream"; 7 | 8 | enum Level { 9 | error, 10 | warn, 11 | info, 12 | http, 13 | debug 14 | } 15 | 16 | type TOutStream = Writable & { 17 | writer?: WriteStream; 18 | }; 19 | 20 | class Logger { 21 | 22 | public readonly logFile: string; 23 | public readonly isConsole: boolean; 24 | public readonly isLog: boolean; 25 | public level: Level; 26 | private readonly outStream?: TOutStream; 27 | 28 | public constructor(logFile: string, isConsole: boolean, isLog: boolean) { 29 | this.logFile = logFile; 30 | this.isConsole = isConsole; 31 | this.isLog = isLog; 32 | this.level = Level.info; 33 | 34 | if (isLog) { 35 | fs.mkdirsSync(path.dirname(logFile)); // has to be sync because of constructor 36 | this.outStream = logrotateStream({ 37 | file: logFile, 38 | size: "1M", 39 | compress: true, 40 | keep: 5 41 | }) as TOutStream; 42 | } 43 | } 44 | 45 | public async shutdownGracefully(): Promise { 46 | if (!this.outStream) return; 47 | this.outStream.end("\n"); 48 | return new Promise((resolve) => { 49 | (this.outStream!.writer ?? this.outStream!).on("finish", resolve); 50 | }); 51 | } 52 | 53 | private log(level: Level, message: unknown): void { 54 | 55 | if (level > this.level) return; 56 | 57 | const time = new Date(); 58 | const timeFormatted = `${time.getHours().toString().padStart(2, "0")}:${ 59 | time.getMinutes().toString().padStart(2, "0")}:${time.getSeconds().toString().padStart(2, "0")}`; 60 | const messageFormatted = message instanceof Error ? message.stack ?? message.message : message; 61 | const formatted = `${`[${Level[level].toUpperCase()}]`.padEnd(7)} ${timeFormatted} ${messageFormatted}`; 62 | 63 | if (this.outStream?.writable) { 64 | this.outStream.write(`${formatted}\n`); 65 | } 66 | 67 | if (this.isConsole) { 68 | let color = chalk.white, func = console.log; 69 | switch (level) { 70 | case Level.error: 71 | color = chalk.red; 72 | func = console.error; 73 | break; 74 | case Level.warn: 75 | color = chalk.yellow; 76 | break; 77 | } 78 | func(color(formatted)); 79 | } 80 | } 81 | 82 | public error(message: unknown): void { 83 | this.log(Level.error, message); 84 | } 85 | 86 | public warn(message: unknown): void { 87 | this.log(Level.warn, message); 88 | } 89 | 90 | public info(message: unknown): void { 91 | this.log(Level.info, message); 92 | } 93 | 94 | public http(message: unknown): void { 95 | this.log(Level.http, message); 96 | } 97 | 98 | public debug(message: unknown): void { 99 | this.log(Level.debug, message); 100 | } 101 | } 102 | 103 | export { Level }; 104 | export default Logger; -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | const zipper = require("zip-local"); 2 | const esbuild = require("esbuild"); 3 | const { nodeExternalsPlugin } = require("esbuild-node-externals"); 4 | const fs = require("fs-extra"); 5 | const { Listr } = require("listr2"); 6 | const path = require("path"); 7 | const packageJson = require("../package.json"); 8 | 9 | const copyPackageJson = async () => { 10 | const distDir = path.join(__dirname, "../dist"); 11 | await fs.writeFile(path.join(distDir, ".version"), packageJson.version); 12 | await fs.writeFile(path.join(distDir, "package.json"), JSON.stringify({ 13 | name: packageJson.name, 14 | version: packageJson.version, 15 | author: packageJson.author, 16 | main: "index.js", 17 | license: packageJson.license, 18 | scripts: { 19 | start: "node index.js" 20 | }, 21 | dependencies: packageJson.dependencies, 22 | optionalDependencies: packageJson.optionalDependencies 23 | })); 24 | }; 25 | 26 | const zip = () => { 27 | 28 | const inPath = path.join(__dirname, "../dist/"); 29 | const outDir = path.join(__dirname, "../dist/"); 30 | const outPath = path.join(outDir, "marlinraker.zip"); 31 | 32 | return new Promise((resolve, reject) => { 33 | zipper.zip(inPath, (error, zipped) => { 34 | if (error) { 35 | reject(error); 36 | return; 37 | } 38 | fs.emptyDir(outDir).then(() => { 39 | zipped.compress(); 40 | zipped.save(outPath, (error) => { 41 | if (error) reject(error); 42 | else resolve(); 43 | }); 44 | }); 45 | }); 46 | }); 47 | }; 48 | 49 | (async () => { 50 | 51 | const { execa } = await import("execa"); 52 | 53 | const tasks = new Listr([ 54 | { 55 | title: "Linting", 56 | task: () => execa("eslint", ["src", "--fix", "--max-warnings=0"]) 57 | }, 58 | { 59 | title: "Clearing dist directory", 60 | task: () => fs.emptyDir("dist/") 61 | }, 62 | { 63 | title: "Static type checking", 64 | task: () => execa("tsc", ["--noEmit"]) 65 | }, 66 | { 67 | title: "Compiling", 68 | task: () => esbuild.build({ 69 | entryPoints: ["src/Server.ts"], 70 | bundle: true, 71 | platform: "node", 72 | outfile: "dist/index.js", 73 | plugins: [nodeExternalsPlugin()], 74 | minify: true, 75 | sourcemap: "inline", 76 | define: { 77 | "process.env.NODE_ENV": JSON.stringify("production") 78 | }, 79 | loader: { 80 | ".toml": "text" 81 | } 82 | }) 83 | }, 84 | { 85 | title: "Copy package.json", 86 | task: () => copyPackageJson() 87 | }, 88 | { 89 | title: "Zipping", 90 | enabled: _ => process.argv.includes("--zip"), 91 | task: () => zip() 92 | } 93 | ]); 94 | 95 | try { 96 | await tasks.run(); 97 | console.log("Done!"); 98 | } catch (e) { 99 | process.exit(1); 100 | } 101 | 102 | })(); 103 | -------------------------------------------------------------------------------- /docs/advanced/macro-scripting.md: -------------------------------------------------------------------------------- 1 | # Macro Scripting 2 | 3 | ## Advanced G-code scripting 4 | 5 | !!! warning 6 | 7 | Advanced macros allow execution of code. 8 | Make sure to only use macros from trusted sources. 9 | 10 | G-code macros are evaluated using [Template literals](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals). 11 | This allows you to use regular JavaScript in your custom macros. 12 | 13 | ### Arguments 14 | 15 | Arguments passed to the macro are available through the `args` object. 16 | If a value has been assigned to the parameter, its value is available as a `string`. 17 | If only the parameter is provided, and it doesn't have a value, it is assigned 18 | `true`. Take this command for example: 19 | 20 | `` 21 | my_macro foo=bar baz 22 | `` 23 | 24 | The `args` object would look like this: 25 | 26 | ```javascript 27 | const args = { 28 | foo: "bar", 29 | baz: true 30 | } 31 | ``` 32 | 33 | --- 34 | 35 | Let's look at a macro in practice: 36 | ```toml 37 | [macros.list_arguments] 38 | gcode = """ 39 | ${Object.entries(args).map(([key, value]) => `M118 E1 ${key}=${value}`).join("\n")} 40 | """ 41 | ``` 42 | 43 | To understand what this macro does, let's break it down in JavaScript: 44 | ```javascript 45 | const gcode = ` 46 | ${ 47 | Object.entries(args) 48 | .map(([key, value]) => 49 | `M118 E1 ${key}=${value}`) 50 | .join("\n") 51 | } 52 | `; 53 | ``` 54 | 55 | As you can see, the macro maps all arguments into a `key=value` format, prepended 56 | by `M118 E1` and are joined to a single string separated by a newline character. 57 | This results in all arguments passed to this macro being printed to the console. 58 | Paste this macro into your configuration and try it out yourself! 59 | 60 | ### Printer object 61 | 62 | Marlinraker also passes a `printer` object to macros. The printer object is 63 | defined as follows: 64 | 65 | ```typescript 66 | declare const printer: { 67 | state: "ready" | "error" | "shutdown" | "startup"; 68 | stateMessage: string; 69 | x: number; 70 | y: number; 71 | z: number; 72 | e: number; 73 | hasEmergencyParser: boolean; 74 | speedFactor: number; 75 | extrudeFactor: number; 76 | fanSpeed: number; 77 | capabilities: Record; 78 | isAbsolute: boolean; 79 | isAbsoluteE: boolean; 80 | feedrate: number; 81 | isM73Supported: boolean; 82 | isPrusa: boolean; 83 | info: { 84 | machineType: string; 85 | firmwareName: string; 86 | }; 87 | pauseState?: { 88 | x: number; 89 | y: number; 90 | isAbsolute: boolean; 91 | isAbsoluteE: boolean; 92 | feedrate: number; 93 | }; 94 | printJob?: { 95 | state: "standby" | "printing" | "paused" | "complete" | "cancelled" | "error"; 96 | filepath: string; 97 | filename: string; 98 | filePosition: number; 99 | progress: number; 100 | isPrinting: boolean; 101 | isReadyToPrint: boolean; 102 | }; 103 | }; 104 | ``` 105 | 106 | For example, this macro would print Marlinraker's current state to 107 | the console: 108 | 109 | ```toml 110 | [macros.print_status] 111 | gcode = """ 112 | M118 E1 ${printer.state} 113 | """ 114 | ``` 115 | 116 | The `printer` object is read-only and available for all macros. -------------------------------------------------------------------------------- /docs/troubleshooting/stuttering-and-blobs.md: -------------------------------------------------------------------------------- 1 | # Stuttering and Blobs 2 | 3 | ## Identifying stuttering 4 | 5 | When printing with Marlinraker, it is possible that you may 6 | notice small blobs and zits on printed walls, especially 7 | on curved surfaces or corners. They might look something 8 | like this: 9 | 10 | ![](../assets/blobs.png){ width="400" } 11 | 12 | There are many reasons for these blobs to occur. One of these 13 | reasons is stuttering. Stuttering happens when the printer can't 14 | keep up with G-code sent to it and the toolhead has to pause before 15 | new commands come in. If you see your toolhead not moving smoothly 16 | in curved sections you very likely have this issue. Try printing 17 | a test object like a cylinder once with Marlinraker and once from 18 | a USB stick or SD card. If the blobs on your print were caused by 19 | stuttering it should have gone away when using an external storage 20 | device. 21 | 22 | ## How to fix stuttering 23 | 24 | ### Marlinraker settings 25 | 26 | Firstly, make sure to always keep Marlinraker up to date for the latest 27 | bugfixes and performance improvements. 28 | 29 | Some Marlinraker settings can negatively impact print performance 30 | and thus cause stuttering. Check these settings in your configuration 31 | to make sure you get the best performance: 32 | 33 | ```toml 34 | # marlinraker.toml 35 | 36 | [misc] 37 | # ... 38 | extended_logs = false 39 | report_velocity = false 40 | ``` 41 | 42 | ``extended_logs`` logs every line of G-code sent to the printer in 43 | a text file. This negatively impacts print performance. Disable this 44 | option if it is enabled. 45 | 46 | ``report_velocity`` sends M114 G-codes in very short time intervals 47 | to compute toolhead velocity and extruder velocity. Some firmwares 48 | pause after receiving a M114 command which causes stuttering, so try 49 | disabling this option too. 50 | 51 | Also, try choosing as high of a baud rate as possible. The higher the 52 | baud rate, the more throughput the serial port has. Ideally, set 53 | ``baud_rate`` to ``"auto"``. 54 | 55 | ### Arc Welder 56 | 57 | Most modern slicers create curved perimeters by dividing them into a 58 | bunch of smaller straight line moves. This results in a lot of 59 | unnecessary commands having to be sent to the printer by the host. 60 | [Arc Welder](https://github.com/FormerLurker/ArcWelderLib) aims to 61 | mitigate this issue by replacing these straight line segments with 62 | arcs. This does not only significantly reduce file sizes, but also 63 | allows for a much lower serial throughput. If your printer supports 64 | G2/G3 G-codes, try using this software. You can use it with Marlinraker 65 | by adding it as a post-processing script. 66 | 67 | ### Marlin configuration 68 | 69 | If you have access to Marlin's ``Configuration.h`` and ``Configuration_adv.h`` 70 | files, you could try changing following settings: 71 | 72 | #### Configuration.h 73 | 74 | ``` 75 | #define BAUDRATE 250000 76 | ``` 77 | 78 | Set the baud rate as high as possible to increase serial throughput. 79 | 80 | #### Configuration_adv.h 81 | 82 | ``` 83 | #define BLOCK_BUFFER_SIZE 32 84 | ``` 85 | 86 | Increase the block buffer size so that more moves can be stored in the planner. 87 | 88 | ``` 89 | #define AUTO_REPORT_POSITION 90 | ``` 91 | 92 | Enable M154 G-code so that no M114 commands have to be sent to the printer. 93 | 94 | ### Marlinraker host 95 | 96 | While printing, check the CPU usage of your host machine. This usually 97 | shouldn't be a problem, but a low power SBC like an RPI zero can have 98 | problems keeping up when there are other processes running, like a 99 | system update for example. -------------------------------------------------------------------------------- /src/files/FileDirectory.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import path from "path"; 3 | import { IDirectory } from "./IDirectory"; 4 | import { IFile } from "./IFile"; 5 | import PrintJob from "../printer/jobs/PrintJob"; 6 | import ErrnoException = NodeJS.ErrnoException; 7 | import { logger, rootDir } from "../Server"; 8 | 9 | class FileDirectory implements IDirectory { 10 | 11 | public readonly root: IDirectory; 12 | public readonly path: string; 13 | public readonly dirname: string; 14 | public readonly permissions: string; 15 | public size?: number | undefined; 16 | public modified?: number | undefined; 17 | 18 | public constructor(root: IDirectory | null, dirpath: string, dirname: string, fullyLoaded?: () => void) { 19 | this.path = dirpath; 20 | this.dirname = dirname; 21 | this.permissions = "rw"; 22 | this.root = root ?? this; 23 | 24 | fs.pathExists(this.path).then(async (exists) => { 25 | if (!exists) { 26 | await fs.mkdirs(this.path); 27 | } 28 | const stat = await fs.stat(this.path); 29 | this.size = stat.size; 30 | this.modified = stat.mtimeMs / 1000; 31 | fullyLoaded?.(); 32 | }).catch((e) => logger.error(e)); 33 | } 34 | 35 | public async getFiles(): Promise { 36 | const filenames = await fs.readdir(this.path); 37 | const files = (await Promise.all(filenames.map(async (file) => [ 38 | file, 39 | await fs.stat(path.join(this.path, file)) 40 | ]))) 41 | .filter((stats) => (stats[1] as fs.Stats).isFile()) 42 | .map((stats) => stats[0] as string); 43 | 44 | return (await Promise.all(files.map(async (name) => this.getFile(name)))) 45 | .filter((file): file is IFile => file !== null); 46 | } 47 | 48 | public async getFile(name: string): Promise { 49 | 50 | return new Promise((resolve) => { 51 | fs.stat(path.join(this.path, name), (err, stat) => { 52 | if (err as ErrnoException | undefined) resolve(null); 53 | resolve({ 54 | filename: name, 55 | modified: stat.mtimeMs / 1000, 56 | permissions: "rw", 57 | size: stat.size, 58 | getPrintJob: () => new PrintJob(path.relative( 59 | path.join(rootDir, "gcodes"), path.join(this.path, name) 60 | ).replaceAll("\\", "/")) 61 | }); 62 | }); 63 | }); 64 | } 65 | 66 | public async getSubDirs(): Promise { 67 | const dirNames = await fs.readdir(this.path); 68 | const dirs = (await Promise.all(dirNames.map(async (dir) => [ 69 | dir, 70 | await fs.stat(path.join(this.path, dir)) 71 | ]))) 72 | .filter((stats) => (stats[1] as fs.Stats).isDirectory()) 73 | .map((stats) => stats[0] as string); 74 | 75 | return (await Promise.all(dirs.map(async (dir) => this.getSubDir(dir)))) 76 | .filter((dir): dir is IDirectory => dir !== null); 77 | } 78 | 79 | public async getSubDir(name: string): Promise { 80 | const subdirPath = path.join(this.path, name); 81 | if (await fs.pathExists(subdirPath)) { 82 | return new Promise((resolve) => { 83 | const dir = new FileDirectory(this, path.join(this.path, name), name, () => { 84 | resolve(dir); 85 | }); 86 | }); 87 | } 88 | return null; 89 | } 90 | } 91 | 92 | export default FileDirectory; -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## Installation with MarlinrakerOS 4 | 5 | If you are planning to use Marlinraker on a Raspberry Pi, you can use 6 | the prebuilt [MarlinrakerOS](https://github.com/pauhull/marlinrakeros) image. 7 | It includes everything you need to run Marlinraker and is the easiest way 8 | to get started. 9 | 10 | First, download the latest MarlinrakerOS release from 11 | [GitHub](https://github.com/pauhull/MarlinrakerOS/releases/tag/0.1.2) 12 | and place the zip file on your computer. Next, download and install 13 | the [Raspberry Pi Imager](https://www.raspberrypi.com/software/). 14 | After installing and opening it, you should see following window: 15 | 16 |
17 | ![](assets/pi_imager.png){ width="500" } 18 |
19 | 20 | Click on "Choose OS", scroll down to "Use custom" and select the 21 | zip file you just downloaded. 22 | 23 | Next, click on the settings button in the bottom right. There you 24 | can choose a hostname under which Marlinraker will be reachable 25 | and a password for your machine. 26 | 27 | Finally, connect your SD card to your computer, click on "Choose storage" 28 | and select it. Click on "Write" to write the image to your SD card. After 29 | writing is finished, you can unplug your SD card from your computer and 30 | insert it into your Raspberry Pi. Congratulations, you successfully installed 31 | Marlinraker! 32 | 33 | Now type in the hostname you entered in Raspberry Pi Imager into your browser. 34 | For example, if your hostname is `my-printer`, you should go to `http://my-printer/`. 35 | Now you should see the default Mainsail interface Marlinraker ships with. 36 | 37 | ![](assets/mainsail.png) 38 | 39 | From here you can make all adjustments you need, you don't have to SSH into your 40 | Raspberry Pi. **Make sure to update Marlinraker, your web interface and system 41 | packages to the latest version under the "Machine" tab.** 42 | 43 | After [configuring](configuration.md) Marlinraker and connecting your 44 | printer to the Raspberry Pi with USB you are good to go. 45 | Happy printing! 46 | 47 | ## Manual installation 48 | 49 | !!! warning 50 | 51 | Manual installation is only recommended for advanced users. This section doesn't 52 | include a definitive tutorial on how to manually install Marlinraker, it just 53 | roughly explains the steps you need to do to get Marlinraker working. 54 | 55 | 1. Marlinraker needs [Node.js](https://nodejs.org/) to run. Make sure to have at 56 | least Node.js 16 installed. 57 | 2. [Download](https://github.com/pauhull/marlinraker/releases/latest) the latest 58 | release of Marlinraker and unzip it to your desired location. Note that the 59 | `marlinraker_files` directory will be located next to the directory the files 60 | are unzipped to. 61 | 3. Set up a system service that runs Marlinraker with `npm run start` in your 62 | Marlinraker directory. See [here](https://github.com/pauhull/MarlinrakerOS/blob/master/src/modules/marlinraker/filesystem/etc/systemd/system/marlinraker.service) 63 | for an example. 64 | 4. Set up a web server that serves the `marlinraker_files/www` directory and forwards 65 | the API endpoints to the port that Marlinraker runs on (`7125` by default). See 66 | [here](https://github.com/pauhull/MarlinrakerOS/blob/master/src/modules/nginx/filesystem/etc/nginx/nginx.conf) 67 | for an example using Nginx. 68 | 5. Create [update scripts](advanced/update-manager.md) to make use of Marlinraker's 69 | integrated update manager. See [here](https://github.com/pauhull/MarlinrakerOS/tree/master/src/modules/marlinraker/filesystem/home/pi/marlinraker_files/update_scripts) 70 | for examples. If Marlinraker is running on linux, make sure your user has sudo 71 | rights and is not password prompted for when using sudo. -------------------------------------------------------------------------------- /src/system/ServiceManager.ts: -------------------------------------------------------------------------------- 1 | import { config, logger } from "../Server"; 2 | import { exec, execSync } from "child_process"; 3 | import MarlinRaker from "../MarlinRaker"; 4 | import SimpleNotification from "../api/notifications/SimpleNotification"; 5 | 6 | interface IActiveService { 7 | active_state: string; 8 | sub_state: string; 9 | } 10 | 11 | class ServiceManager { 12 | 13 | public readonly activeServiceList: string[]; 14 | public readonly activeServices: Record; 15 | private readonly marlinRaker: MarlinRaker; 16 | private readonly allowedServices: string[]; 17 | 18 | public constructor(marlinRaker: MarlinRaker) { 19 | this.marlinRaker = marlinRaker; 20 | this.allowedServices = config.getStringArray("misc.allowed_services", 21 | ["marlinraker", "crowsnest", "MoonCord", "moonraker-telegram-bot", 22 | "KlipperScreen", "sonar", "webcamd"]); 23 | this.activeServices = this.getActiveServices(); 24 | this.activeServiceList = Object.keys(this.activeServices); 25 | 26 | if (this.activeServiceList.length) { 27 | setInterval(this.updateServiceState.bind(this), 1000); 28 | } 29 | } 30 | 31 | public async systemctl(action: "start" | "stop" | "restart", service: string): Promise { 32 | if (!this.activeServiceList.includes(service)) return; 33 | return new Promise((resolve, reject) => { 34 | exec(`sudo systemctl ${action} ${service}`, (error) => { 35 | if (error) reject(error); 36 | else resolve(); 37 | }); 38 | }); 39 | } 40 | 41 | private getActiveServices(): Record { 42 | 43 | if (process.platform !== "linux") { 44 | logger.info("Not running on linux, service manager disabled"); 45 | return {}; 46 | } 47 | 48 | try { 49 | return Object.fromEntries(execSync("systemctl list-units --all --type=service --plain --no-legend") 50 | .toString("utf-8") 51 | .split("\n") 52 | .map((line) => line.split(".")[0]) 53 | .filter((service) => this.allowedServices.includes(service)) 54 | .map((service) => [ 55 | service, 56 | { 57 | active_state: "unknown", 58 | sub_state: "unknown" 59 | } 60 | ])); 61 | } catch (e) { 62 | logger.warn("Cannot get active system services, service manager disabled"); 63 | logger.debug(e); 64 | return {}; 65 | } 66 | } 67 | 68 | private updateServiceState(): void { 69 | exec(`systemctl show -p ActiveState,SubState --value ${this.activeServiceList.join(" ")}`, (error, stdout) => { 70 | if (error) return; 71 | const lines = stdout.split("\n").filter((line) => line !== ""); 72 | for (let i = 0; i < this.activeServiceList.length; i++) { 73 | const service = this.activeServices[this.activeServiceList[i]]; 74 | const oldActiveState = service.active_state; 75 | const oldSubState = service.sub_state; 76 | service.active_state = lines[i * 2]; 77 | service.sub_state = lines[i * 2 + 1]; 78 | if (service.active_state !== oldActiveState || service.sub_state !== oldSubState) { 79 | void this.marlinRaker.socketHandler.broadcast(new SimpleNotification("notify_service_state_changed", [ 80 | { 81 | [this.activeServiceList[i]]: service 82 | } 83 | ])); 84 | } 85 | } 86 | }); 87 | } 88 | } 89 | 90 | export { IActiveService }; 91 | export default ServiceManager; -------------------------------------------------------------------------------- /src/api/http/FileHandler.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import multer from "multer"; 3 | import fs from "fs-extra"; 4 | import path from "path"; 5 | import crypto from "crypto"; 6 | import { rootDir, router } from "../../Server"; 7 | import MarlinRaker from "../../MarlinRaker"; 8 | 9 | class FileHandler { 10 | 11 | private readonly marlinRaker: MarlinRaker; 12 | 13 | public constructor(marlinRaker: MarlinRaker) { 14 | this.marlinRaker = marlinRaker; 15 | this.handleUpload("/server/files/upload"); 16 | this.handleDelete(); 17 | FileHandler.handleDownload(); 18 | } 19 | 20 | private static handleDownload(): void { 21 | router.use("/server/files/gcodes/", express.static(path.join(rootDir, "gcodes"))); 22 | router.use("/server/files/config/", express.static(path.join(rootDir, "config"))); 23 | } 24 | 25 | private handleDelete(): void { 26 | router.delete("/server/files/:filepath(*)", async (req, res) => { 27 | const filepath = req.params.filepath; 28 | try { 29 | const response = await this.marlinRaker.fileManager.deleteFile(filepath); 30 | res.send(response); 31 | } catch (e) { 32 | res.status(400).send(e); 33 | } 34 | }); 35 | } 36 | 37 | public handleUpload(url: string): void { 38 | const upload = multer({ storage: multer.diskStorage({}) }); 39 | 40 | router.post(url, upload.single("file"), async (req, res) => { 41 | 42 | if (!req.file) { 43 | res.status(400).send(); 44 | return; 45 | } 46 | 47 | const checksum = req.body.checksum; 48 | const root = req.body.root ?? "gcodes"; 49 | const filepath = req.body.path ?? ""; 50 | const print = req.body.print === "true"; 51 | const filename = req.file.originalname; 52 | const source = req.file.path; 53 | 54 | if (checksum) { 55 | const computedChecksum = await FileHandler.sha256(source); 56 | if (checksum !== computedChecksum) { 57 | res.status(422).send(); 58 | await fs.remove(source); 59 | return; 60 | } 61 | } 62 | 63 | let response; 64 | try { 65 | response = await this.marlinRaker.fileManager.uploadFile(root, filepath, filename, source); 66 | } catch (e) { 67 | res.status(400).send(e); 68 | return; 69 | } 70 | 71 | if (print) { 72 | let printStarted = false; 73 | if (await this.marlinRaker.jobManager.selectFile(path.join("gcodes", filepath, filename) 74 | .replaceAll("\\", "/"))) { 75 | await this.marlinRaker.dispatchCommand("start_print", false); 76 | printStarted = true; 77 | } 78 | 79 | res.send({ 80 | ...response, 81 | print_started: printStarted 82 | }); 83 | } else { 84 | res.send(response); 85 | } 86 | }); 87 | } 88 | 89 | private static async sha256(filepath: string): Promise { 90 | return new Promise((resolve) => { 91 | const hash = crypto.createHash("sha256"); 92 | hash.setEncoding("hex"); 93 | 94 | const stream = fs.createReadStream(filepath); 95 | 96 | stream.on("end", () => { 97 | hash.end(); 98 | resolve(hash.read()); 99 | }); 100 | 101 | stream.pipe(hash); 102 | }); 103 | } 104 | } 105 | 106 | export default FileHandler; -------------------------------------------------------------------------------- /docs/advanced/update-manager.md: -------------------------------------------------------------------------------- 1 | # Update Manager 2 | 3 | ## Update scripts 4 | 5 | Marlinraker will load all scripts contained in `marlinraker_files/update_scripts/` 6 | and use them for updating components. Update scripts show up in the update manager 7 | with the same name as the file has (excluding the file extension). File names starting 8 | with `_` are ignored. 9 | 10 | When a script is called with `-i` as an argument, it should print a status information 11 | JSON object to stdout. See the [Moonraker docs](https://moonraker.readthedocs.io/en/latest/web_api/#get-update-status) 12 | for more information. A basic status information object could look like this: 13 | 14 | ```json 15 | { 16 | "name": "mainsail", 17 | "owner": "mainsail-crew", 18 | "version": "v2.3.1", 19 | "remote_version": "v2.3.1", 20 | "configured_type": "web", 21 | "channel": "stable" 22 | } 23 | ``` 24 | 25 | The `version` and `remote_version` fields **have** to be included for the update 26 | script to work. 27 | 28 | When called with `-u` as an argument, the update script should perform the 29 | actual update. Messages printed to stdout will be sent to the web interface. 30 | 31 | MarlinrakerOS already contains pre-made scripts for Marlinraker, Mainsail and Fluidd. 32 | It also contains `_common.sh`, a bash script that includes multiple utility functions. 33 | 34 | ## Example 35 | 36 | ```shell 37 | #!/bin/bash 38 | 39 | REPO_OWNER="pauhull" 40 | REPO_NAME="marlinraker" 41 | DIR="/home/pi/marlinraker" 42 | 43 | function fetch_latest_release { 44 | echo $(curl -s "https://api.github.com/repos/$1/$2/releases/latest") 45 | } 46 | 47 | function get_remote_version { 48 | version="$(grep -oPm 1 "\"tag_name\": \"\K[^\"]+")" 49 | if [ -z $version ]; then 50 | echo -n "?" 51 | else 52 | echo -n $version 53 | fi 54 | } 55 | 56 | function get_current_version { 57 | if [ -e $1 ]; then 58 | cat "$1/.version" 59 | else 60 | echo -n "?" 61 | fi 62 | } 63 | 64 | function do_update { 65 | latest_release=$(fetch_latest_release $1 $2) 66 | version=$(echo $latest_release | get_remote_version) 67 | download_url=$(echo $latest_release | grep -oPm 1 "\"browser_download_url\": \"\K[^\"]+") 68 | 69 | if [ -z $download_url ]; then 70 | echo "Cannot fetch download url" 71 | exit 1 72 | fi 73 | 74 | mkdir -pv "$3" 75 | cd "$3" 76 | rm -rfv * 77 | wget "$download_url" -O temp.zip 2>&1 78 | unzip -o temp.zip 79 | rm -f temp.zip 80 | rm -f .version 81 | touch .version 82 | echo -n $version >> .version 83 | } 84 | 85 | function get_info { 86 | current_version=$(get_current_version $3) 87 | remote_version=$(echo $(fetch_latest_release $1 $2) | get_remote_version) 88 | 89 | echo -n "{"\ 90 | "\"owner\":\"$1\","\ 91 | "\"name\":\"$2\","\ 92 | "\"version\":\"$current_version\","\ 93 | "\"remote_version\":\"$remote_version\","\ 94 | "\"configured_type\":\"web\","\ 95 | "\"channel\":\"stable\","\ 96 | "\"info_tags\":[]"\ 97 | "}" 98 | } 99 | 100 | function install { 101 | temp_dir=$(mktemp -d) 102 | mv "$DIR/node_modules" "$temp_dir/node_modules" 103 | mv "$DIR/package-lock.json" "$temp_dir/package-lock.json" 104 | do_update $REPO_OWNER $REPO_NAME $DIR 105 | cd $DIR 106 | mv "$temp_dir/node_modules" "$DIR/node_modules" 107 | mv "$temp_dir/package-lock.json" "$DIR/package-lock.json" 108 | rm -r "$temp_dir" 109 | echo "Installing npm packages..." 110 | npm install 111 | sudo systemctl restart marlinraker 112 | } 113 | 114 | if [ $# -eq 0 ]; then 115 | echo "Possible arguments: --info (-i), --update (-u)" 116 | exit 1 117 | fi 118 | 119 | case $1 in 120 | -i|--info) get_info $REPO_OWNER $REPO_NAME $DIR;; 121 | -u|--update) install;; 122 | *) echo "Unknown task \"$1\""; exit 1;; 123 | esac 124 | ``` -------------------------------------------------------------------------------- /src/util/SerialPortSearch.ts: -------------------------------------------------------------------------------- 1 | import Database from "../database/Database"; 2 | import { SerialPort } from "serialport"; 3 | import readline from "readline"; 4 | import { logger } from "../Server"; 5 | 6 | class SerialPortSearch { 7 | 8 | private readonly database: Database; 9 | private readonly baudRate: number; 10 | 11 | public constructor(baudRate: number) { 12 | this.database = new Database(); 13 | this.baudRate = baudRate; 14 | } 15 | 16 | public async findSerialPort(): Promise<[string, number] | null> { 17 | logger.info("Searching for serial port"); 18 | 19 | const lastPort = await this.database.getItem("marlinraker", "serial.last_port"); 20 | const lastBaudRate = this.baudRate || await this.database.getItem("marlinraker", "serial.last_baud_rate"); 21 | if (lastPort && lastBaudRate && typeof lastPort === "string" && typeof lastBaudRate === "number") { 22 | logger.info(`Trying last used port ${lastPort} with baud rate ${lastBaudRate}`); 23 | if (await this.trySerialPort(lastPort, lastBaudRate)) { 24 | logger.info("Success"); 25 | return [lastPort, lastBaudRate]; 26 | } else { 27 | logger.info("Cannot connect. Searching for port..."); 28 | } 29 | } 30 | 31 | await this.database.deleteItem("marlinraker", "serial.last_port"); 32 | await this.database.deleteItem("marlinraker", "serial.last_baud_rate"); 33 | 34 | try { 35 | const paths = (await SerialPort.list()).map((port) => port.path); 36 | 37 | const baudRates = this.baudRate ? [this.baudRate] : [250000, 115200, 19200]; 38 | for (const path of paths) { 39 | for (const baudRate of baudRates) { 40 | logger.info(`Trying ${path} with baud rate ${baudRate}`); 41 | 42 | let success = false; 43 | try { 44 | success = await this.trySerialPort(path, baudRate); 45 | } catch (e) { 46 | logger.error(e); 47 | } 48 | 49 | if (success) { 50 | logger.info("Success"); 51 | await this.database.addItem("marlinraker", "serial.last_port", path); 52 | await this.database.addItem("marlinraker", "serial.last_baud_rate", baudRate); 53 | return [path, baudRate]; 54 | } else { 55 | logger.info("Cannot connect"); 56 | } 57 | } 58 | } 59 | } catch (e) { 60 | logger.error(`Could not scan serial ports: ${e}`); 61 | } 62 | 63 | return null; 64 | } 65 | 66 | private async trySerialPort(path: string, baudRate: number): Promise { 67 | 68 | const ignoreError = (): void => { 69 | // 70 | }; 71 | 72 | const port = new SerialPort({ path, baudRate, autoOpen: false }, ignoreError); 73 | const isOpen = await new Promise((resolve) => { 74 | port.open((err) => { 75 | resolve(!err); 76 | }); 77 | }); 78 | 79 | if (!isOpen) { 80 | return false; 81 | } 82 | 83 | const lineReader = readline.createInterface(port); 84 | const result = await Promise.race([ 85 | new Promise((resolve) => { 86 | setTimeout(() => resolve(false), 2000); 87 | }), 88 | new Promise((resolve) => { 89 | lineReader.on("line", (line) => { 90 | if (line === "ok" || line === "start") { 91 | resolve(true); 92 | } 93 | }); 94 | try { 95 | port.write("M110 N0\n", ignoreError); 96 | } catch (_) { 97 | // 98 | } 99 | }) 100 | ]); 101 | 102 | port.close(ignoreError); 103 | return result; 104 | } 105 | } 106 | 107 | export default SerialPortSearch; -------------------------------------------------------------------------------- /src/printer/macros/MacroManager.ts: -------------------------------------------------------------------------------- 1 | import { IMacro } from "./IMacro"; 2 | import PauseMacro from "./PauseMacro"; 3 | import ResumeMacro from "./ResumeMacro"; 4 | import CancelPrintMacro from "./CancelPrintMacro"; 5 | import CustomMacro from "./CustomMacro"; 6 | import { config, logger } from "../../Server"; 7 | import NamedObjectMap from "../../util/NamedObjectMap"; 8 | import StartPrintMacro from "./StartPrintMacro"; 9 | import SdcardResetFileMacro from "./SdcardResetFileMacro"; 10 | import MarlinRaker from "../../MarlinRaker"; 11 | 12 | class MacroManager { 13 | 14 | public readonly macros; 15 | 16 | public constructor(marlinRaker: MarlinRaker) { 17 | this.macros = new NamedObjectMap([ 18 | new PauseMacro(marlinRaker), 19 | new ResumeMacro(marlinRaker), 20 | new CancelPrintMacro(marlinRaker), 21 | new StartPrintMacro(marlinRaker), 22 | new SdcardResetFileMacro(marlinRaker) 23 | ]); 24 | this.loadMacros(); 25 | } 26 | 27 | public async execute(command: string): Promise { 28 | const calledMacro = command.trim().split(" ")[0].toLowerCase(); 29 | for (const [macroName, macro] of this.macros) { 30 | if (macroName.toLowerCase() === calledMacro) { 31 | const params = Object.fromEntries( 32 | command.substring(macroName.length).trim() 33 | .split(" ") 34 | .filter((s) => s) 35 | .map((s) => s.split("=")) 36 | .map((arr) => [arr[0].toLowerCase(), arr[1] ?? true]) 37 | ); 38 | await macro.execute(params); 39 | return true; 40 | } 41 | } 42 | 43 | return false; 44 | } 45 | 46 | private loadMacros(): void { 47 | 48 | const f = (strings: string[], ...expressions: unknown[]): string => { 49 | let result = strings[0] ?? ""; 50 | for (let i = 0; i < expressions.length; i++) { 51 | const expr = expressions[i]; 52 | if (typeof expr !== "undefined" && expr !== null && expr !== false) result += expr; 53 | result += strings[i + 1] ?? ""; 54 | } 55 | return result; 56 | }; 57 | 58 | const macros = config.getObject("macros", {}); 59 | for (const configName in macros) { 60 | const macroName = configName.toLowerCase(); 61 | 62 | if (!/^[a-z_]+$/.test(macroName)) { 63 | logger.error(`Macro name "${macroName}" is invalid and will not be loaded`); 64 | continue; 65 | } 66 | 67 | const renameExisting = config.getStringIfExists(`macros.${configName}.rename_existing`, null)?.toLowerCase() 68 | ?? `${macroName}_base`; 69 | if (this.macros.has(macroName) && this.macros.has(renameExisting)) { 70 | logger.error(`Cannot rename "${macroName}" to "${renameExisting}": Macro already exists`); 71 | } 72 | 73 | const gcode = config.getString(`macros.${configName}.gcode`, ""); 74 | if (new RegExp(`^\\s*${macroName.replace(/(?=\W)/g, "\\")}(\\s|$)`, "gmi").test(gcode)) { 75 | logger.error(`Error in macro ${macroName}: Cannot call self`); 76 | continue; 77 | } 78 | 79 | try { 80 | const evaluate = new Function("f", "args", "printer", `"use strict";return f\`${gcode}\`;`); 81 | const existing = this.macros.get(macroName); 82 | if (existing) { 83 | this.macros.set(renameExisting, existing); 84 | } 85 | this.macros.set(macroName, new CustomMacro(macroName, (args, printer) => evaluate(f, args, printer))); 86 | 87 | logger.info(`Registered macro "${macroName}"`); 88 | } catch (e) { 89 | logger.error(`Error while registering macro "${macroName}":`); 90 | logger.error(e); 91 | } 92 | } 93 | 94 | for (const [macroName, macro] of this.macros) { 95 | if (macroName === macro.name) { 96 | (config.klipperPseudoConfig as Record)[`gcode_macro ${macroName.toUpperCase()}`] = {}; 97 | } 98 | } 99 | } 100 | } 101 | 102 | export default MacroManager; -------------------------------------------------------------------------------- /src/printer/objects/ObjectManager.ts: -------------------------------------------------------------------------------- 1 | import { WebSocket } from "ws"; 2 | import PrinterObject from "./PrinterObject"; 3 | import SimpleNotification from "../../api/notifications/SimpleNotification"; 4 | import WebhooksObject from "./WebhooksObject"; 5 | import ToolheadObject from "./ToolheadObject"; 6 | import HeatersObject from "./HeatersObject"; 7 | import ConfigFileObject from "./ConfigFileObject"; 8 | import BedMeshObject from "./BedMeshObject"; 9 | import GcodeMoveObject from "./GcodeMoveObject"; 10 | import PrintStatsObject from "./PrintStatsObject"; 11 | import VirtualSdCardObject from "./VirtualSdCardObject"; 12 | import NamedObjectMap from "../../util/NamedObjectMap"; 13 | import FanObject from "./FanObject"; 14 | import SystemStatsObject from "./SystemStatsObject"; 15 | import MotionReportObject from "./MotionReportObject"; 16 | import MarlinRaker from "../../MarlinRaker"; 17 | import IdleTimeoutObject from "./IdleTimeoutObject"; 18 | import PauseResumeObject from "./PauseResumeObject"; 19 | 20 | interface IPrinterObjects { 21 | eventtime: number; 22 | status: Record; 23 | } 24 | 25 | class ObjectManager { 26 | 27 | public readonly objects: NamedObjectMap>; 28 | private subscriptions: { socket: WebSocket; unsubscribeAll: () => void }[] = []; 29 | 30 | public constructor(marlinRaker: MarlinRaker) { 31 | this.objects = new NamedObjectMap>([ 32 | new WebhooksObject(marlinRaker), 33 | new ToolheadObject(marlinRaker), 34 | new FanObject(marlinRaker), 35 | new GcodeMoveObject(marlinRaker), 36 | new MotionReportObject(marlinRaker), 37 | new SystemStatsObject(), 38 | new HeatersObject(marlinRaker), 39 | new ConfigFileObject(marlinRaker), 40 | new PrintStatsObject(marlinRaker), 41 | new VirtualSdCardObject(marlinRaker), 42 | new IdleTimeoutObject(marlinRaker), 43 | new PauseResumeObject(marlinRaker), 44 | new BedMeshObject(marlinRaker) 45 | ]); 46 | } 47 | 48 | public subscribe(socket: WebSocket, objects: Record): IPrinterObjects { 49 | 50 | const existing = this.subscriptions.find((subscription) => subscription.socket === socket); 51 | if (existing) { 52 | existing.unsubscribeAll(); 53 | this.subscriptions = this.subscriptions.filter((subscription) => subscription !== existing); 54 | } 55 | 56 | const status: Record = {}; 57 | const unsubscribers: (() => void)[] = []; 58 | 59 | for (const objectName in objects) { 60 | const topics = objects[objectName]; 61 | const object = this.objects.get(objectName); 62 | if (!object?.isAvailable()) continue; 63 | const subscriber = async (): Promise => { 64 | const diff = object.getDifference(subscriber, topics); 65 | if (!Object.keys(diff).length) return; 66 | socket.send(await new SimpleNotification("notify_status_update", [ 67 | Object.fromEntries([[object.name, diff]]), 68 | process.uptime() 69 | ]).toString()); 70 | }; 71 | object.subscribe(subscriber); 72 | unsubscribers.push(() => object.unsubscribe(subscriber)); 73 | status[object.name] = object.getFull(subscriber, topics); 74 | } 75 | 76 | const closeListener = (): void => { 77 | unsubscribers.forEach((unsubscribe) => unsubscribe()); 78 | }; 79 | socket.on("close", closeListener); 80 | unsubscribers.push(() => socket.off("close", closeListener)); 81 | 82 | this.subscriptions.push({ 83 | socket, 84 | unsubscribeAll() { 85 | unsubscribers.forEach((unsubscribe) => unsubscribe()); 86 | } 87 | }); 88 | 89 | return { 90 | eventtime: process.uptime(), 91 | status 92 | }; 93 | } 94 | 95 | public query(objects: Record): IPrinterObjects { 96 | 97 | const status: Record = {}; 98 | 99 | for (const objectName in objects) { 100 | const topics = objects[objectName]; 101 | const object = this.objects.get(objectName); 102 | if (!object?.isAvailable()) continue; 103 | status[object.name] = object.query(topics); 104 | } 105 | 106 | return { 107 | eventtime: process.uptime(), 108 | status 109 | }; 110 | } 111 | } 112 | 113 | export { IPrinterObjects }; 114 | export default ObjectManager; -------------------------------------------------------------------------------- /src/printer/jobs/PrintJob.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs-extra"; 3 | import { logger, rootDir } from "../../Server"; 4 | import LineReader from "../../files/LineReader"; 5 | import { IGcodeMetadata } from "../../files/MetadataManager"; 6 | import Printer from "../Printer"; 7 | import MarlinRaker from "../../MarlinRaker"; 8 | import { IHistoryJob } from "./JobHistory"; 9 | 10 | class PrintJob { 11 | 12 | public readonly filename: string; 13 | public readonly filepath: string; 14 | public readonly fileSize: number; 15 | public historyJob?: IHistoryJob; 16 | public filePosition: number; 17 | public progress: number; 18 | public metadata?: IGcodeMetadata; 19 | private readonly marlinRaker: MarlinRaker; 20 | private readonly printer: Printer; 21 | private lineReader?: LineReader; 22 | private latestCommand?: Promise; 23 | private onPausedListener?: () => void; 24 | private pauseRequested: boolean; 25 | 26 | public constructor(filename: string) { 27 | this.marlinRaker = MarlinRaker.getInstance(); 28 | if (!this.marlinRaker.printer) throw new Error(); 29 | 30 | this.printer = this.marlinRaker.printer; 31 | this.filename = filename; 32 | this.filepath = path.join(rootDir, "gcodes", filename); 33 | this.filePosition = 0; 34 | this.progress = 0; 35 | this.pauseRequested = false; 36 | 37 | this.printer.on("commandOk", async () => { 38 | await this.flush(); 39 | }); 40 | 41 | try { 42 | this.fileSize = fs.statSync(this.filepath).size; 43 | } catch (_) { 44 | this.fileSize = 0; 45 | } 46 | } 47 | 48 | public async start(): Promise { 49 | this.metadata = await this.marlinRaker.metadataManager.getOrGenerateMetadata(this.filename) ?? undefined; 50 | if (!this.fileSize || !this.metadata) throw new Error("Cannot find file"); 51 | this.pauseRequested = false; 52 | this.lineReader = new LineReader(fs.createReadStream(this.filepath)); 53 | setTimeout(this.flush.bind(this)); 54 | } 55 | 56 | public async finish(): Promise { 57 | await this.waitForPrintMoves(); 58 | if (!this.printer.isPrusa) { 59 | await this.printer.queueGcode("M77", false, false); 60 | } 61 | await this.marlinRaker.jobManager.setState("complete"); 62 | this.progress = 1; 63 | } 64 | 65 | private async flush(): Promise { 66 | if (!this.lineReader) return; 67 | 68 | if (this.pauseRequested) { 69 | if (this.onPausedListener) { 70 | this.onPausedListener(); 71 | delete this.onPausedListener; 72 | } 73 | return; 74 | } 75 | 76 | if (this.marlinRaker.jobManager.state !== "printing" || this.printer.hasCommandsInQueue()) return; 77 | 78 | let nextCommand: string | null = null; 79 | do { 80 | nextCommand = (await this.lineReader.readLine())?.split(";")?.[0] ?? null; 81 | } while (nextCommand !== null && !nextCommand.trim()); 82 | 83 | if (!nextCommand) { 84 | delete this.lineReader; 85 | await this.finish(); 86 | return; 87 | } 88 | 89 | const position = this.lineReader.position; 90 | this.latestCommand = this.printer.queueGcode(nextCommand, false, false); 91 | 92 | this.latestCommand.then(async () => { 93 | this.filePosition = position; 94 | const start = (await this.metadata)?.gcode_start_byte ?? 0; 95 | const end = (await this.metadata)?.gcode_end_byte ?? this.fileSize; 96 | this.progress = Math.min(1, Math.max(0, (position - start) / (end - start))); 97 | }).catch((e) => logger.error(e)); 98 | } 99 | 100 | public async pause(): Promise { 101 | if (this.pauseRequested) return; 102 | const promise = new Promise((resolve) => { 103 | this.onPausedListener = resolve.bind(this); 104 | }); 105 | this.pauseRequested = true; 106 | await promise; 107 | await this.waitForPrintMoves(); 108 | } 109 | 110 | public async resume(): Promise { 111 | this.pauseRequested = false; 112 | setTimeout(this.flush.bind(this)); 113 | } 114 | 115 | public async cancel(): Promise { 116 | if (!this.pauseRequested) { 117 | const promise = new Promise((resolve) => { 118 | this.onPausedListener = resolve.bind(this); 119 | }); 120 | this.pauseRequested = true; 121 | await promise; 122 | } 123 | await this.waitForPrintMoves(); 124 | } 125 | 126 | private async waitForPrintMoves(): Promise { 127 | await this.latestCommand; 128 | await this.printer.queueGcode("M400", false, false); 129 | } 130 | } 131 | 132 | export { PrintJob }; 133 | export default PrintJob; -------------------------------------------------------------------------------- /src/compat/KlipperCompat.ts: -------------------------------------------------------------------------------- 1 | import MarlinRaker from "../MarlinRaker"; 2 | 3 | class KlipperCompat { 4 | 5 | private readonly marlinRaker: MarlinRaker; 6 | 7 | public constructor(marlinRaker: MarlinRaker) { 8 | this.marlinRaker = marlinRaker; 9 | } 10 | 11 | public translateCommand(klipperCommand: string): (() => Promise) | null { 12 | 13 | if (/^SET_HEATER_TEMPERATURE(\s|$)/i.test(klipperCommand)) { 14 | 15 | if (!this.marlinRaker.printer) return null; 16 | const args = klipperCommand.split(" ").slice(1); 17 | 18 | const heaterName = args.find((s) => s.toUpperCase().startsWith("HEATER="))?.substring(7); 19 | if (!heaterName) { 20 | throw new Error("missing HEATER"); 21 | } 22 | const marlinHeater = Array.from(this.marlinRaker.printer.heaterManager.klipperHeaterNames.entries()) 23 | .find(([_, value]) => value === heaterName)?.[0]; 24 | if (!marlinHeater) { 25 | throw new Error(`The value '${heaterName}' is not valid for HEATER`); 26 | } 27 | 28 | const targetStr = args.find((s) => s.toUpperCase().startsWith("TARGET="))?.substring(7); 29 | if (!targetStr) { 30 | throw new Error("missing TARGET"); 31 | } 32 | let target: number; 33 | try { 34 | target = Number.parseFloat(targetStr); 35 | } catch (e) { 36 | throw new Error(`unable to parse ${targetStr}`); 37 | } 38 | 39 | if (marlinHeater.startsWith("T")) { 40 | return async () => { 41 | if (!this.marlinRaker.printer) return; 42 | const param = marlinHeater + (marlinHeater.length === 1 ? "0" : ""); 43 | await this.marlinRaker.printer.queueGcode(`M104 ${param} S${target}`, false, false); 44 | }; 45 | } else if (marlinHeater === "B") { 46 | return async () => { 47 | if (!this.marlinRaker.printer) return; 48 | await this.marlinRaker.printer.queueGcode(`M140 S${target}`, false, false); 49 | }; 50 | } else { 51 | throw new Error("Internal error"); 52 | } 53 | 54 | } else if (/^BED_MESH_CALIBRATE(\s|$)/i.test(klipperCommand)) { 55 | 56 | /* 57 | Klipper doesn't do anything with the profile name so I won't too 58 | const profileName = klipperCommand.split(" ") 59 | .find(s => s.toUpperCase().startsWith("PROFILE=")) 60 | ?.substring(8) ?? "default"; 61 | */ 62 | return async () => { 63 | if (!this.marlinRaker.printer) return; 64 | const response = await this.marlinRaker.printer.queueGcode("G29 V4", false, false); 65 | if (!response) return; 66 | 67 | // Bed X: >???< Y: >???< Z: ??? 68 | const [minX, minY, maxX, maxY] = response.split(/\r?\n/) 69 | .filter((s) => s.startsWith("Bed")) 70 | .map((s) => s.split(" ") 71 | .filter((_, i) => i === 2 || i === 4) 72 | .map((f) => Number.parseFloat(f)) 73 | ) 74 | .map((arr) => [arr[0], arr[1], arr[0], arr[1]]) 75 | .reduce((a, b) => [ 76 | Math.min(a[0], b[0]), Math.min(a[1], b[1]), 77 | Math.max(a[2], b[2]), Math.max(a[3], b[3]) 78 | ]); 79 | 80 | const grid: number[][] = []; 81 | response.split(/\r?\n/) 82 | .filter((s) => s.startsWith(" ")) 83 | .map((s) => s.trim().split(" ")) 84 | .map((arr) => arr.map((s) => Number.parseFloat(s))) 85 | .forEach((arr) => grid[arr[0]] = arr.slice(1)); 86 | 87 | const mean = grid.flat().reduce((a, b) => a + b) / grid.flat().length; 88 | grid.forEach((arr) => arr.forEach((_, i) => arr[i] = Math.round((arr[i] - mean) * 100) / 100)); 89 | 90 | this.marlinRaker.printer.emit("updateBedMesh", { 91 | grid, 92 | min: [minX, minY], 93 | max: [maxX, maxY], 94 | profile: "default" 95 | }); 96 | }; 97 | 98 | } else if (/^RESTART(\s|$)/i.test(klipperCommand)) { 99 | return async () => { 100 | await this.marlinRaker.restart(); 101 | }; 102 | 103 | } else if (/^FIRMWARE_RESTART(\s|$)/i.test(klipperCommand)) { 104 | return async () => { 105 | await this.marlinRaker.reconnect(); 106 | }; 107 | 108 | } else if (/^TURN_OFF_HEATERS(\s|$)/i.test(klipperCommand)) { 109 | return async () => { 110 | if (this.marlinRaker.printer) { 111 | await this.marlinRaker.printer.queueGcode("M104 S0\nM140 S0", false, false); 112 | } 113 | }; 114 | } 115 | return null; 116 | } 117 | } 118 | 119 | export default KlipperCompat; -------------------------------------------------------------------------------- /src/printer/HeaterManager.ts: -------------------------------------------------------------------------------- 1 | import Printer from "./Printer"; 2 | import { THeaters } from "./ParserUtil"; 3 | import TemperatureObject from "./objects/TemperatureObject"; 4 | import MarlinRaker from "../MarlinRaker"; 5 | import TypedEventEmitter from "../util/TypedEventEmitter"; 6 | 7 | interface ITempRecord { 8 | temperatures?: number[]; 9 | targets?: number[]; 10 | powers?: number[]; 11 | } 12 | 13 | interface IHeaterManagerEvents { 14 | availableSensorsUpdate: () => void; 15 | } 16 | 17 | class HeaterManager extends TypedEventEmitter { 18 | 19 | private static readonly RECORD_CAP = 1200; 20 | 21 | public readonly records: Map; 22 | public readonly klipperHeaterNames: Map; 23 | public readonly availableHeaters: string[]; 24 | public readonly availableSensors: string[]; 25 | private readonly timer: NodeJS.Timer; 26 | private readonly tempObjects: Map; 27 | private readonly marlinRaker: MarlinRaker; 28 | private readonly printer: Printer; 29 | 30 | public constructor(marlinRaker: MarlinRaker, printer: Printer) { 31 | super(); 32 | this.marlinRaker = marlinRaker; 33 | this.printer = printer; 34 | this.availableHeaters = []; 35 | this.availableSensors = []; 36 | this.records = new Map(); 37 | this.klipperHeaterNames = new Map(); 38 | this.tempObjects = new Map(); 39 | 40 | this.timer = setInterval(() => { 41 | for (const tempObject of this.tempObjects.values()) { 42 | tempObject.emit(); 43 | } 44 | this.updateTempRecords(); 45 | }, 1000); 46 | } 47 | 48 | public updateTemps(heaters: THeaters): void { 49 | for (const heaterName in heaters) { 50 | const heater = heaters[heaterName]; 51 | 52 | let klipperName = this.klipperHeaterNames.get(heaterName); 53 | if (!klipperName) { 54 | klipperName = heaterName; 55 | if (heaterName.startsWith("T")) { 56 | const id = Number.parseInt(heaterName.substring(1)) || 0; 57 | klipperName = id ? `extruder${id}` : "extruder"; 58 | } else if (heaterName === "B") { 59 | klipperName = "heater_bed"; 60 | } else if (heaterName === "A") { 61 | klipperName = "temperature_sensor ambient"; 62 | } else if (heaterName === "P") { 63 | klipperName = "temperature_sensor pinda"; 64 | } 65 | this.klipperHeaterNames.set(heaterName, klipperName); 66 | } 67 | 68 | let tempObject = this.tempObjects.get(klipperName); 69 | if (!tempObject) { 70 | tempObject = new TemperatureObject(klipperName); 71 | this.tempObjects.set(klipperName, tempObject); 72 | this.marlinRaker.objectManager.objects.add(tempObject); 73 | 74 | this.availableSensors.push(klipperName); 75 | if (heater.power !== undefined) { 76 | this.availableHeaters.push(klipperName); 77 | } 78 | 79 | const tempRecord: ITempRecord = {}; 80 | if (heater.temp !== undefined) tempRecord.temperatures = new Array(HeaterManager.RECORD_CAP).fill(0); 81 | if (heater.target !== undefined) tempRecord.targets = new Array(HeaterManager.RECORD_CAP).fill(0); 82 | if (heater.power !== undefined) tempRecord.powers = new Array(HeaterManager.RECORD_CAP).fill(0); 83 | this.records.set(klipperName, tempRecord); 84 | 85 | this.emit("availableSensorsUpdate"); 86 | } 87 | 88 | tempObject.temp = heater.temp ?? 0; 89 | tempObject.target = heater.target; 90 | tempObject.power = heater.power; 91 | tempObject.minTemp = Math.min(tempObject.minTemp ?? Infinity, tempObject.temp); 92 | tempObject.maxTemp = Math.max(tempObject.maxTemp ?? -Infinity, tempObject.temp); 93 | } 94 | } 95 | 96 | public cleanup(): void { 97 | clearInterval(this.timer); 98 | for (const [klipperName] of this.tempObjects) { 99 | this.marlinRaker.objectManager.objects.delete(klipperName); 100 | } 101 | this.removeAllListeners(); 102 | } 103 | 104 | private updateTempRecords(): void { 105 | for (const [klipperName, record] of this.records) { 106 | const tempObject = this.tempObjects.get(klipperName); 107 | if (!tempObject) continue; 108 | HeaterManager.updateTempRecord(record, "temperatures", tempObject.temp); 109 | HeaterManager.updateTempRecord(record, "targets", tempObject.target); 110 | HeaterManager.updateTempRecord(record, "powers", tempObject.power); 111 | } 112 | } 113 | 114 | private static updateTempRecord(record: ITempRecord, recordKey: keyof ITempRecord, value?: number): void { 115 | const arr = record[recordKey]; 116 | if (!arr) return; 117 | arr.push(value ?? 0); 118 | arr.shift(); 119 | } 120 | } 121 | 122 | export { ITempRecord }; 123 | export default HeaterManager; -------------------------------------------------------------------------------- /src/Server.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response, Router, static as serveStatic } from "express"; 2 | import http from "http"; 3 | import MarlinRaker from "./MarlinRaker"; 4 | import { WebSocketServer } from "ws"; 5 | import Config from "./config/Config"; 6 | import path from "path"; 7 | import fs from "fs-extra"; 8 | import { SerialPort } from "serialport"; 9 | import sourceMapSupport from "source-map-support"; 10 | import Logger, { Level } from "./logger/Logger"; 11 | import cors from "cors"; 12 | 13 | sourceMapSupport.install({ handleUncaughtExceptions: false }); 14 | 15 | let config: Config; 16 | let rootDir: string; 17 | let logger: Logger; 18 | let router: Router; 19 | 20 | (async (): Promise => { 21 | 22 | // there is no logger available nor necessary at this point 23 | /* eslint-disable no-console */ 24 | if (process.getuid?.() === 0) { 25 | console.error("Please do not run this program as root"); 26 | process.exit(1); 27 | } 28 | 29 | if (process.argv.some((s) => s.toLowerCase() === "--find-ports")) { 30 | const ports = await SerialPort.list(); 31 | console.log(`Possible ports: ${ports.map((port) => port.path).join(", ")}`); 32 | process.exit(0); 33 | return; 34 | } 35 | /* eslint-enable no-console */ 36 | 37 | rootDir = path.resolve(process.env.MARLINRAKER_DIR ?? "../marlinraker_files/"); 38 | await fs.mkdirs(rootDir); 39 | 40 | const logFile = path.join(rootDir, "logs/marlinraker.log"); 41 | const isConsole = !process.argv.slice(1).find((s) => s.toLowerCase() === "--silent"); 42 | const isLog = !process.argv.slice(1).find((s) => s.toLowerCase() === "--no-log"); 43 | logger = new Logger(logFile, isConsole, isLog); 44 | 45 | process.on("uncaughtException", async (e) => { 46 | logger.error(e); 47 | await logger.shutdownGracefully(); 48 | process.exit(0); 49 | }); 50 | 51 | const configFile = path.join(rootDir, "config/marlinraker.toml"); 52 | await fs.mkdirs(path.dirname(configFile)); 53 | if (!await fs.pathExists(configFile)) { 54 | const defaultConfig = process.env.NODE_ENV === "production" 55 | ? (await import("../config/marlinraker.toml")).default 56 | : await fs.readFile("config/marlinraker.toml"); 57 | await fs.writeFile(configFile, defaultConfig); 58 | 59 | const printerConfigFile = path.join(rootDir, "config/printer.toml"); 60 | if (!await fs.pathExists(printerConfigFile)) { 61 | const defaultPrinterConfig = process.env.NODE_ENV === "production" 62 | ? (await import("../config/printers/generic.toml")).default 63 | : await fs.readFile("config/printers/generic.toml"); 64 | await fs.writeFile(printerConfigFile, defaultPrinterConfig); 65 | } 66 | } 67 | config = new Config(configFile); 68 | 69 | const isDebug = config.getBoolean("misc.extended_logs", false) 70 | || process.argv.some((s) => s.toLowerCase() === "--extended-logs"); 71 | if (isDebug) { 72 | logger.level = Level.debug; 73 | } 74 | 75 | const app = express(); 76 | 77 | const corsDomains = config.getStringArray("web.cors_domains", []); 78 | if (corsDomains.length) { 79 | app.use(cors({ 80 | origin: corsDomains 81 | })); 82 | } else { 83 | app.use(cors()); 84 | logger.warn("No CORS domains found. Disabling CORS."); 85 | } 86 | 87 | if (isDebug) { 88 | app.use((req, _, next) => { 89 | logger.http(`${req.method} ${req.url} ${req.body ?? ""}`); 90 | next(); 91 | }); 92 | } 93 | 94 | router = express.Router(); 95 | app.use(router); 96 | 97 | const wwwDir = path.join(rootDir, "www/"); 98 | await fs.mkdirs(wwwDir); 99 | const isServeStatic = process.argv.some((s) => s.toLowerCase() === "--serve-static"); 100 | if (isServeStatic) { 101 | app.use(serveStatic(wwwDir)); 102 | } 103 | 104 | const logHandler = (_: Request, res: Response): void => { 105 | if (logger.isLog) { 106 | res.download(logger.logFile, path.basename(logger.logFile)); 107 | } else { 108 | res.status(404).send(); 109 | } 110 | }; 111 | app.get("/server/files/klippy.log", logHandler); 112 | app.get("/server/files/moonraker.log", logHandler); 113 | app.get("/server/files/marlinraker.log", logHandler); 114 | 115 | const sendApi404 = (_: unknown, res: Response): void => { 116 | res.status(404); 117 | res.type("json"); 118 | res.send({ 119 | error: { 120 | code: 404, 121 | message: "Not Found", 122 | traceback: "" 123 | } 124 | }); 125 | }; 126 | ["printer", "api", "access", "machine", "server"].forEach((api) => { 127 | app.get(`/${api}/*`, sendApi404); 128 | app.post(`/${api}/*`, sendApi404); 129 | }); 130 | 131 | app.get("*", (req, res) => { 132 | if (isServeStatic) { 133 | res.sendFile(path.join(rootDir, "www/index.html")); 134 | } else { 135 | res.status(404).send(); 136 | } 137 | }); 138 | 139 | const httpServer = http.createServer(app); 140 | const wss = new WebSocketServer({ server: httpServer, path: "/websocket" }); 141 | 142 | const httpPort = config.getNumber("web.port", 7125); 143 | httpServer.listen(httpPort); 144 | logger.info(`App listening on port ${httpPort}`); 145 | 146 | new MarlinRaker(wss); 147 | })().catch((e) => { 148 | throw e; 149 | }); 150 | 151 | export { config, rootDir, logger, router }; --------------------------------------------------------------------------------