@vextrix3d/machine-agent 0.0.0-stage → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,93 @@
1
+ import { backoffDelayMs } from './utils.js';
2
+ const STATE_REPORT_INTERVAL_MS = 120_000;
3
+ const PROGRESS_REPORT_STEP_PCT = 5;
4
+ export const createPrinterStateReporter = ({ printerLabel, reportPrinterState, printerGone, }) => {
5
+ let lastReportedState = null;
6
+ let lastReportedAt = 0;
7
+ let pendingState = null;
8
+ let reportingPromise = null;
9
+ let failures = 0;
10
+ let retryTimeoutId;
11
+ let isStopped = false;
12
+ const shouldReport = (state) => {
13
+ if (!lastReportedState) {
14
+ return true;
15
+ }
16
+ if (state.state !== lastReportedState.state ||
17
+ state.hasCameraStream !== lastReportedState.hasCameraStream) {
18
+ return true;
19
+ }
20
+ const previousProgress = lastReportedState.progressPct;
21
+ const currentProgress = state.progressPct;
22
+ if (previousProgress !== null &&
23
+ currentProgress !== null &&
24
+ Math.abs(currentProgress - previousProgress) >= PROGRESS_REPORT_STEP_PCT) {
25
+ return true;
26
+ }
27
+ return Date.now() - lastReportedAt >= STATE_REPORT_INTERVAL_MS;
28
+ };
29
+ const canSend = () => !isStopped && !printerGone.isGone();
30
+ const send = () => {
31
+ if (reportingPromise || !pendingState || !canSend()) {
32
+ return;
33
+ }
34
+ const state = pendingState;
35
+ pendingState = null;
36
+ reportingPromise = reportPrinterState(state)
37
+ .then(() => {
38
+ lastReportedState = state;
39
+ lastReportedAt = Date.now();
40
+ failures = 0;
41
+ if (pendingState && !shouldReport(pendingState)) {
42
+ pendingState = null;
43
+ }
44
+ }, error => {
45
+ if (printerGone.notice(error)) {
46
+ pendingState = null;
47
+ return;
48
+ }
49
+ pendingState ??= state;
50
+ failures++;
51
+ const delayMs = backoffDelayMs(failures, 30_000, 600_000);
52
+ console.error(`Failed to report state for "${printerLabel}"; trying again in ${delayMs / 1000}s:`, error);
53
+ if (canSend()) {
54
+ retryTimeoutId = setTimeout(() => {
55
+ retryTimeoutId = undefined;
56
+ send();
57
+ }, delayMs);
58
+ }
59
+ })
60
+ .finally(() => {
61
+ reportingPromise = null;
62
+ if (retryTimeoutId === undefined) {
63
+ send();
64
+ }
65
+ });
66
+ };
67
+ return {
68
+ report: state => {
69
+ if (!canSend() || (!pendingState && !shouldReport(state))) {
70
+ return;
71
+ }
72
+ pendingState = state;
73
+ if (retryTimeoutId === undefined) {
74
+ send();
75
+ }
76
+ },
77
+ flush: async () => {
78
+ if (!reportingPromise && retryTimeoutId !== undefined) {
79
+ clearTimeout(retryTimeoutId);
80
+ retryTimeoutId = undefined;
81
+ send();
82
+ }
83
+ while (reportingPromise) {
84
+ await reportingPromise;
85
+ }
86
+ },
87
+ stop: () => {
88
+ isStopped = true;
89
+ clearTimeout(retryTimeoutId);
90
+ retryTimeoutId = undefined;
91
+ },
92
+ };
93
+ };
@@ -0,0 +1,10 @@
1
+ export var BambuGcodeState;
2
+ (function (BambuGcodeState) {
3
+ BambuGcodeState["IDLE"] = "IDLE";
4
+ BambuGcodeState["RUNNING"] = "RUNNING";
5
+ BambuGcodeState["PAUSE"] = "PAUSE";
6
+ BambuGcodeState["FAILED"] = "FAILED";
7
+ BambuGcodeState["CANCEL"] = "CANCEL";
8
+ BambuGcodeState["FINISH"] = "FINISH";
9
+ BambuGcodeState["PREPARE"] = "PREPARE";
10
+ })(BambuGcodeState || (BambuGcodeState = {}));
@@ -0,0 +1,2 @@
1
+ export * from './bambu.types.js';
2
+ export * from './moonraker.types.js';
@@ -0,0 +1,16 @@
1
+ export var MoonrakerPrintState;
2
+ (function (MoonrakerPrintState) {
3
+ MoonrakerPrintState["STANDBY"] = "standby";
4
+ MoonrakerPrintState["PRINTING"] = "printing";
5
+ MoonrakerPrintState["PAUSED"] = "paused";
6
+ MoonrakerPrintState["COMPLETE"] = "complete";
7
+ MoonrakerPrintState["CANCELLED"] = "cancelled";
8
+ MoonrakerPrintState["ERROR"] = "error";
9
+ })(MoonrakerPrintState || (MoonrakerPrintState = {}));
10
+ export var KlippyState;
11
+ (function (KlippyState) {
12
+ KlippyState["READY"] = "ready";
13
+ KlippyState["STARTUP"] = "startup";
14
+ KlippyState["SHUTDOWN"] = "shutdown";
15
+ KlippyState["ERROR"] = "error";
16
+ })(KlippyState || (KlippyState = {}));
package/dist/utils.js ADDED
@@ -0,0 +1,233 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { accessSync, constants, createReadStream } from 'node:fs';
3
+ import { createServer } from 'node:net';
4
+ import { MachineAgentStateType } from './clients/Vextrix3dApiClient.js';
5
+ import { BambuGcodeState, KlippyState, MoonrakerPrintState, } from './types/index.js';
6
+ export function assert(condition, message) {
7
+ if (!condition) {
8
+ throw new Error(message);
9
+ }
10
+ }
11
+ export const backoffDelayMs = (failures, baseMs, maxMs) => Math.min(maxMs, baseMs * 2 ** (failures - 1));
12
+ export const isExecutable = (path) => {
13
+ try {
14
+ accessSync(path, constants.X_OK);
15
+ return true;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ };
21
+ export const requireEnvVar = (name) => {
22
+ const value = process.env[name]?.trim();
23
+ assert(value, `The ${name} environment variable is not configured`);
24
+ return value;
25
+ };
26
+ export const once = (fn) => {
27
+ let promise = null;
28
+ return () => {
29
+ promise ??= fn();
30
+ return promise;
31
+ };
32
+ };
33
+ export const isPortFree = (port) => new Promise(resolve => {
34
+ const server = createServer();
35
+ server.once('error', () => resolve(false));
36
+ server.listen({
37
+ host: '127.0.0.1',
38
+ port,
39
+ }, () => server.close(() => resolve(true)));
40
+ });
41
+ export const sha256File = async (path) => {
42
+ const hash = createHash('sha256');
43
+ for await (const chunk of createReadStream(path)) {
44
+ hash.update(chunk);
45
+ }
46
+ return hash.digest('hex');
47
+ };
48
+ export const describeError = (error) => {
49
+ if (!(error instanceof Error)) {
50
+ return String(error);
51
+ }
52
+ return error.cause instanceof Error ? `${error.message} (${error.cause.message})` : error.message;
53
+ };
54
+ export const readArmVersion = () => {
55
+ const armVersion = process.config.variables.arm_version;
56
+ return armVersion === undefined ? undefined : String(armVersion);
57
+ };
58
+ export const stopChildProcess = (child, killAfterMs = 3_000) => new Promise(resolve => {
59
+ const killTimeoutId = setTimeout(() => {
60
+ child.kill('SIGKILL');
61
+ resolve();
62
+ }, killAfterMs);
63
+ child.once('exit', () => {
64
+ clearTimeout(killTimeoutId);
65
+ resolve();
66
+ });
67
+ child.kill('SIGTERM');
68
+ });
69
+ export const formatSubtaskName = (value) => {
70
+ const subtaskName = value?.trim();
71
+ if (!subtaskName) {
72
+ return null;
73
+ }
74
+ const isCut = subtaskName.endsWith('...');
75
+ const names = (isCut ? subtaskName.slice(0, -3) : subtaskName).split(' + ');
76
+ const cutName = isCut ? names.pop()?.replace(/ \+?$/, '') : undefined;
77
+ const lastName = names.at(-1);
78
+ const counts = new Map();
79
+ names.forEach(name => {
80
+ counts.set(name, (counts.get(name) ?? 0) + 1);
81
+ });
82
+ const atLeastName = cutName && lastName?.startsWith(cutName) ? lastName : null;
83
+ if (atLeastName) {
84
+ counts.set(atLeastName, (counts.get(atLeastName) ?? 0) + 1);
85
+ }
86
+ const formattedNames = [...counts].map(([name, count]) => {
87
+ if (count === 1) {
88
+ return name;
89
+ }
90
+ return name === atLeastName ? `${name} ×${count}+` : `${name} ×${count}`;
91
+ });
92
+ if (cutName !== undefined && !atLeastName) {
93
+ formattedNames.push(`${cutName}...`);
94
+ }
95
+ return formattedNames.join(' + ');
96
+ };
97
+ const isReportObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
98
+ const mergeReportObjects = (status, report) => {
99
+ const merged = { ...status };
100
+ Object.entries(report).forEach(([key, value]) => {
101
+ const statusValue = merged[key];
102
+ merged[key] =
103
+ isReportObject(statusValue) && isReportObject(value)
104
+ ? mergeReportObjects(statusValue, value)
105
+ : value;
106
+ });
107
+ return merged;
108
+ };
109
+ export const mergeBambuStatus = (status, report) => status
110
+ ? mergeReportObjects(status, report)
111
+ : report;
112
+ export const readBambuTemperature = (value) => typeof value === 'number' ? value & 0xffff : null;
113
+ const BAMBU_GCODE_STATE_MAP = {
114
+ [BambuGcodeState.IDLE]: MachineAgentStateType.IDLE,
115
+ [BambuGcodeState.RUNNING]: MachineAgentStateType.RUNNING,
116
+ [BambuGcodeState.PAUSE]: MachineAgentStateType.PAUSED,
117
+ [BambuGcodeState.FAILED]: MachineAgentStateType.FAILED,
118
+ [BambuGcodeState.CANCEL]: MachineAgentStateType.CANCELLED,
119
+ [BambuGcodeState.FINISH]: MachineAgentStateType.FINISHED,
120
+ [BambuGcodeState.PREPARE]: MachineAgentStateType.PREPARING,
121
+ };
122
+ const mapBambuGcodeState = (state) => {
123
+ if (typeof state === 'string' &&
124
+ Object.values(BambuGcodeState).includes(state)) {
125
+ return BAMBU_GCODE_STATE_MAP[state];
126
+ }
127
+ if (state) {
128
+ console.warn('Unknown Bambu gcode state', {
129
+ state,
130
+ });
131
+ }
132
+ return MachineAgentStateType.IDLE;
133
+ };
134
+ const BAMBU_PRINT_CANCELLED_ERROR = 0x0300400c;
135
+ export const createBambuStateResolver = () => {
136
+ let failedAs = null;
137
+ return (status) => {
138
+ const state = mapBambuGcodeState(status.gcode_state);
139
+ if (state !== MachineAgentStateType.FAILED) {
140
+ failedAs = null;
141
+ return state;
142
+ }
143
+ if (status.print_error === BAMBU_PRINT_CANCELLED_ERROR) {
144
+ failedAs = MachineAgentStateType.CANCELLED;
145
+ }
146
+ failedAs ??= MachineAgentStateType.FAILED;
147
+ return failedAs;
148
+ };
149
+ };
150
+ export const MOONRAKER_SUBSCRIBED_OBJECTS = {
151
+ webhooks: ['state'],
152
+ print_stats: ['state', 'filename', 'print_duration'],
153
+ display_status: ['progress'],
154
+ extruder: ['temperature'],
155
+ heater_bed: ['temperature'],
156
+ };
157
+ const MOONRAKER_CHAMBER_OBJECT = /^(temperature_sensor|heater_generic|temperature_fan) .*chamber/i;
158
+ export const findMoonrakerChamberObject = (objects) => objects.find(object => MOONRAKER_CHAMBER_OBJECT.test(object)) ?? null;
159
+ export const mergeMoonrakerStatus = (status, update) => mergeReportObjects(status, update);
160
+ const readNumber = (object, field) => {
161
+ const value = object?.[field];
162
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
163
+ };
164
+ const MOONRAKER_PRINT_STATE_MAP = {
165
+ [MoonrakerPrintState.STANDBY]: MachineAgentStateType.IDLE,
166
+ [MoonrakerPrintState.PRINTING]: MachineAgentStateType.RUNNING,
167
+ [MoonrakerPrintState.PAUSED]: MachineAgentStateType.PAUSED,
168
+ [MoonrakerPrintState.COMPLETE]: MachineAgentStateType.FINISHED,
169
+ [MoonrakerPrintState.CANCELLED]: MachineAgentStateType.CANCELLED,
170
+ [MoonrakerPrintState.ERROR]: MachineAgentStateType.FAILED,
171
+ };
172
+ export const resolveMoonrakerState = (status) => {
173
+ const klippyState = status.webhooks?.state;
174
+ if (klippyState === KlippyState.SHUTDOWN || klippyState === KlippyState.ERROR) {
175
+ return MachineAgentStateType.FAILED;
176
+ }
177
+ const printState = status.print_stats?.state;
178
+ if (typeof printState === 'string' &&
179
+ Object.values(MoonrakerPrintState).includes(printState)) {
180
+ return MOONRAKER_PRINT_STATE_MAP[printState];
181
+ }
182
+ if (printState) {
183
+ console.warn('Unknown Moonraker print state', {
184
+ state: printState,
185
+ });
186
+ }
187
+ return MachineAgentStateType.IDLE;
188
+ };
189
+ export const formatMoonrakerJobName = (filename) => {
190
+ if (typeof filename !== 'string') {
191
+ return null;
192
+ }
193
+ return (filename
194
+ .split('/')
195
+ .at(-1)
196
+ ?.replace(/\.gcode$/i, '')
197
+ .trim() || null);
198
+ };
199
+ export const estimateMoonrakerRemainingMinutes = ({ printDurationS, progress, estimatedTimeS, }) => {
200
+ if (!printDurationS || printDurationS < 1) {
201
+ return null;
202
+ }
203
+ let remainingS = null;
204
+ if (estimatedTimeS) {
205
+ remainingS = estimatedTimeS - printDurationS;
206
+ }
207
+ else if (progress) {
208
+ remainingS = printDurationS / progress - printDurationS;
209
+ }
210
+ return remainingS === null ? null : Math.max(0, Math.round(remainingS / 60));
211
+ };
212
+ export const buildMoonrakerAgentState = (status, estimatedTimeS) => {
213
+ const state = resolveMoonrakerState(status);
214
+ const isUnderWay = state === MachineAgentStateType.RUNNING || state === MachineAgentStateType.PAUSED;
215
+ const progress = readNumber(status.display_status, 'progress');
216
+ const chamberObject = findMoonrakerChamberObject(Object.keys(status));
217
+ return {
218
+ state,
219
+ subtaskName: formatMoonrakerJobName(status.print_stats?.filename),
220
+ progressPct: progress === null ? null : Math.round(progress * 100),
221
+ remainingMinutes: isUnderWay
222
+ ? estimateMoonrakerRemainingMinutes({
223
+ printDurationS: readNumber(status.print_stats, 'print_duration'),
224
+ progress,
225
+ estimatedTimeS,
226
+ })
227
+ : null,
228
+ nozzleTemperature: readNumber(status.extruder, 'temperature'),
229
+ bedTemperature: readNumber(status.heater_bed, 'temperature'),
230
+ chamberTemperature: chamberObject ? readNumber(status[chamberObject], 'temperature') : null,
231
+ wifiSignalDbm: null,
232
+ };
233
+ };
package/package.json CHANGED
@@ -1,6 +1,71 @@
1
1
  {
2
2
  "name": "@vextrix3d/machine-agent",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.0",
4
+ "description": "Connects a Bambu or Klipper printer to Vextrix3D: reports its state and print progress, and relays its camera",
5
+ "keywords": [
6
+ "vextrix3d",
7
+ "3d-printing",
8
+ "printer",
9
+ "bambu-lab",
10
+ "klipper",
11
+ "moonraker",
12
+ "mqtt",
13
+ "go2rtc"
14
+ ],
15
+ "author": "Mike Aliinyk <m.aliynik@gmail.com>",
16
+ "license": "SEE LICENSE IN LICENSE",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/Vextrix3D/machine-agent.git"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/Vextrix3D/machine-agent/issues"
23
+ },
24
+ "homepage": "https://github.com/Vextrix3D/machine-agent",
25
+ "type": "module",
26
+ "bin": {
27
+ "machine-agent": "dist/bootstrap.js"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "certs",
32
+ "!dist/**/__tests__"
33
+ ],
34
+ "engines": {
35
+ "node": ">=22.12"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
42
+ "typecheck": "tsc --noEmit -p tsconfig.json",
43
+ "lint": "eslint src && prettier --check src",
44
+ "test": "vitest",
45
+ "prepublishOnly": "CI=1 npm test && npm run build",
46
+ "start": "npm run build && pm2 start dist/bootstrap.js --name \"$(basename \"$(pwd -P)\")\"",
47
+ "start:prod": "npm run build && NODE_ENV=production pm2 start dist/bootstrap.js --name \"$(basename \"$(pwd -P)\")\"",
48
+ "restart": "npm run build && pm2 restart \"$(basename \"$(pwd -P)\")\"",
49
+ "restart:prod": "npm run build && NODE_ENV=production pm2 restart \"$(basename \"$(pwd -P)\")\" --update-env",
50
+ "stop": "pm2 stop \"$(basename \"$(pwd -P)\")\"",
51
+ "delete": "pm2 delete \"$(basename \"$(pwd -P)\")\"",
52
+ "logs": "pm2 logs \"$(basename \"$(pwd -P)\")\" --time"
53
+ },
54
+ "dependencies": {
55
+ "mqtt": "5.16.1"
56
+ },
57
+ "devDependencies": {
58
+ "@eslint/js": "10.0.1",
59
+ "@types/node": "24.19.2",
60
+ "eslint": "10.14.0",
61
+ "pm2": "7.1.2",
62
+ "prettier": "3.10.1",
63
+ "ts-loader": "9.6.2",
64
+ "ts-node": "10.9.2",
65
+ "tsc-alias": "1.9.10",
66
+ "typescript": "6.0.3",
67
+ "typescript-eslint": "8.72.2",
68
+ "vite": "8.3.8",
69
+ "vitest": "5.1.0"
70
+ }
71
+ }