Compare commits

..

2 commits

Author SHA1 Message Date
dlawler489
7c893e50bd Merge read-only Bambu Lab printer connection
All checks were successful
build-and-push / docker (push) Successful in 31s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:16:36 +10:00
dlawler489
93130967af Connect to a Bambu Lab printer, read-only
Settings → Printer takes the printer's address, serial number and access code
(the code stored as a secret, like the other tokens). "Save & test connection"
connects to the printer's own MQTT broker on port 8883, subscribes to its
reports, asks once for a full report, and listens for a few seconds — then
says what it heard: its state, and the filament in each AMS slot or on the
external spool holder, with how much is left where the printer knows.

Nothing sent to the printer changes what it is doing; the only thing published
is the request for a full report. If the printer is in cloud mode and treats
even that as a write, the test says it received nothing rather than failing
silently. The access code never leaves the server.

Adds mqtt as a runtime dependency — server.js now loads it at startup, so the
lockfile has to travel with this change.

Not verified against a real printer yet: the printer's ports answer on the
network, but no connection has been made with its credentials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:15:15 +10:00
8 changed files with 688 additions and 2 deletions

View file

@ -28,6 +28,7 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX,
- **Search by meaning** — switch the search box to *Meaning* to find models by what they are ("wall hook" finds a "coat hanger bracket"). Uses a small embedding model (bge-small-en-v1.5, ~34 MB) that runs on the server, so it's free and private; the index updates in the background as models change. - **Search by meaning** — switch the search box to *Meaning* to find models by what they are ("wall hook" finds a "coat hanger bracket"). Uses a small embedding model (bge-small-en-v1.5, ~34 MB) that runs on the server, so it's free and private; the index updates in the background as models change.
- **Automatic tags** — strict whole-word rules over names, file names and archive contents (part types, mechanisms, materials, techniques) plus your own "word → tag" rules. Switch it off (optionally removing its tags) or re-apply it any time. Tags you add yourself are never changed; AI and rule tags are marked on the model page. - **Automatic tags** — strict whole-word rules over names, file names and archive contents (part types, mechanisms, materials, techniques) plus your own "word → tag" rules. Switch it off (optionally removing its tags) or re-apply it any time. Tags you add yourself are never changed; AI and rule tags are marked on the model page.
- **Filament & what's printable** — keep the spools you own (material, colour, brand, grams left) on the **Filament** page. Bambu Studio / OrcaSlicer projects name the filament in each slot, which Printhive reads once per model in the background, so cards and the model page say whether you can print it now and what you're short of, and the toolbar can show only what's printable. Each slot is matched to a spool by material *and* colour — a four-colour print needs those four colours, and one spool can't fill two slots. Near shades count as the same colour; the model page lists every slot, which spool fills it, and the nearest thing you own when none does. Models that aren't slicer projects don't name their filaments, and say so rather than being called unprintable. The slot list can be edited on the model page when the project's own list is wrong or you plan to print it differently — set a slot to **any colour** when the shade doesn't matter, and it's filled by any spool of that material. Edited lists are marked as yours and left alone by the background reader; "Use the project's list" puts it back. Adding a spool can start from Bambu Lab's own filament list — pick "PLA Basic — Jade White (10100)" and the material, colour and code fill themselves — and slot colours are named where they match it. That list is included by kind permission of [3D Filament Profiles](https://3dfilamentprofiles.com), whose terms otherwise reserve it; ask them before reusing `db/bambu-filaments.json` elsewhere. - **Filament & what's printable** — keep the spools you own (material, colour, brand, grams left) on the **Filament** page. Bambu Studio / OrcaSlicer projects name the filament in each slot, which Printhive reads once per model in the background, so cards and the model page say whether you can print it now and what you're short of, and the toolbar can show only what's printable. Each slot is matched to a spool by material *and* colour — a four-colour print needs those four colours, and one spool can't fill two slots. Near shades count as the same colour; the model page lists every slot, which spool fills it, and the nearest thing you own when none does. Models that aren't slicer projects don't name their filaments, and say so rather than being called unprintable. The slot list can be edited on the model page when the project's own list is wrong or you plan to print it differently — set a slot to **any colour** when the shade doesn't matter, and it's filled by any spool of that material. Edited lists are marked as yours and left alone by the background reader; "Use the project's list" puts it back. Adding a spool can start from Bambu Lab's own filament list — pick "PLA Basic — Jade White (10100)" and the material, colour and code fill themselves — and slot colours are named where they match it. That list is included by kind permission of [3D Filament Profiles](https://3dfilamentprofiles.com), whose terms otherwise reserve it; ask them before reusing `db/bambu-filaments.json` elsewhere.
- **Printer** — connect a Bambu Lab printer on your network in Settings → Printer (its address, serial number and access code), and "Save & test connection" listens to it for a few seconds and reports what it said: its state and the filament in each AMS slot or on the external spool holder. The connection is read-only — Printhive never sends the printer a command — and the access code stays on your server.
- **Trash** — deleting moves a model to the trash; restore it, delete it forever, or empty the trash (only then are files removed from disk). - **Trash** — deleting moves a model to the trash; restore it, delete it forever, or empty the trash (only then are files removed from disk).
- **Settings** — API tokens / FlareSolverr URL stored in the database (with `.env` fallback), secrets masked. - **Settings** — API tokens / FlareSolverr URL stored in the database (with `.env` fallback), secrets masked.
@ -72,6 +73,9 @@ Settings live in the database (editable in the app's **Settings** menu) and fall
| AI API address | `AI_BASE_URL` | Defaults per provider; e.g. `http://localhost:11434/v1` for Ollama | | AI API address | `AI_BASE_URL` | Defaults per provider; e.g. `http://localhost:11434/v1` for Ollama |
| AI prices | `AI_PRICE_INPUT`, `AI_PRICE_OUTPUT` | $ per million tokens, only for showing costs (DeepSeek defaults to its peak rates) | | AI prices | `AI_PRICE_INPUT`, `AI_PRICE_OUTPUT` | $ per million tokens, only for showing costs (DeepSeek defaults to its peak rates) |
| Send thumbnails | `AI_SEND_IMAGES` | `false` to tag from names and files only | | Send thumbnails | `AI_SEND_IMAGES` | `false` to tag from names and files only |
| Printer address | `PRINTER_HOST` | A Bambu Lab printer's IP on your network |
| Printer serial number | `PRINTER_SERIAL` | Shown on the printer's screen and sticker |
| Printer access code | `PRINTER_ACCESS_CODE` | From the printer's network settings; kept on the server |
## Notes ## Notes

99
lib/printer.js Normal file
View file

@ -0,0 +1,99 @@
// A read-only connection to a Bambu Lab printer on your network.
//
// The printer runs an MQTT broker on port 8883 — TLS with a self-signed
// certificate, username "bblp", and the access code from its screen as the
// password. Printhive only subscribes to the printer's reports and asks once for
// a full one ("pushall"); it never sends anything that changes what the printer
// is doing.
const mqtt = require('mqtt');
const PORT = 8883;
// One tray, as the printer describes it. `remain` is a percentage; the printer
// sends -1 when it can't tell, which is mostly the case for non-Bambu spools.
function describeTray(tray, unit = null) {
const loaded = Boolean(tray && tray.tray_type);
return {
unit,
slot: tray && tray.id != null ? Number(tray.id) : null,
empty: !loaded,
material: loaded ? tray.tray_type : null,
colour: loaded && tray.tray_color ? `#${String(tray.tray_color).slice(0, 6).toUpperCase()}` : null,
profile: loaded ? tray.tray_info_idx || null : null, // Bambu's filament profile, e.g. GFA00
brand: loaded ? tray.tray_sub_brands || null : null,
remain: loaded && Number(tray.remain) >= 0 ? Number(tray.remain) : null,
};
}
// Every tray in every AMS unit, in order.
function describeAms(units) {
const trays = [];
for (const unit of Array.isArray(units) ? units : []) {
for (const tray of unit.tray || []) trays.push(describeTray(tray, Number(unit.id)));
}
return trays;
}
// The messages the MQTT library gives are terse; say what they mean.
function friendlyError(err) {
const m = String((err && err.message) || err || '');
if (/not authori[sz]ed|bad user ?name or password/i.test(m)) return 'The printer refused the access code.';
if (/ECONNREFUSED/.test(m)) return 'Nothing answered on port 8883 at that address.';
if (/ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|timed? ?out/i.test(m)) return 'Couldn’t reach the printer from the server.';
return m.slice(0, 200) || 'The connection failed.';
}
// Connects, asks for a full report, and gathers what arrives for a few seconds.
// Resolves with what was seen — never with the access code.
function probe({ host, serial, accessCode, waitMs = 8000 }) {
return new Promise((resolve) => {
const started = Date.now();
const seen = { connected: false, reports: 0, printer: null, ams: null, external: null, error: null };
let done = false;
let timer = null;
const client = mqtt.connect(`mqtts://${host}:${PORT}`, {
username: 'bblp',
password: accessCode,
rejectUnauthorized: false, // the printer's certificate is self-signed
connectTimeout: 6000,
reconnectPeriod: 0, // one attempt: this is a test, not a session
clientId: `printhive-${Math.random().toString(16).slice(2, 10)}`,
});
const finish = () => {
if (done) return;
done = true;
clearTimeout(timer);
client.end(true);
resolve({ ...seen, ms: Date.now() - started });
};
timer = setTimeout(finish, waitMs);
client.on('connect', () => {
seen.connected = true;
client.subscribe(`device/${serial}/report`, (err) => {
if (err) { seen.error = `The printer wouldn’t let Printhive listen: ${err.message}`; return; }
// The printer only sends everything when asked.
client.publish(`device/${serial}/request`,
JSON.stringify({ pushing: { sequence_id: '0', command: 'pushall' } }));
});
});
client.on('message', (_topic, payload) => {
seen.reports += 1;
let msg;
try { msg = JSON.parse(payload.toString()); } catch { return; }
const p = msg && msg.print;
if (!p) return;
if (p.gcode_state) seen.printer = { ...(seen.printer || {}), state: p.gcode_state };
if (p.mc_percent != null) seen.printer = { ...(seen.printer || {}), progress: Number(p.mc_percent) };
if (p.ams && Array.isArray(p.ams.ams)) seen.ams = describeAms(p.ams.ams);
if (p.vt_tray) seen.external = describeTray(p.vt_tray, null);
});
client.on('error', (err) => {
seen.error = friendlyError(err);
finish();
});
client.on('close', () => { if (!seen.connected) finish(); });
});
}
module.exports = { probe, describeAms, describeTray, friendlyError };

463
package-lock.json generated
View file

@ -17,6 +17,7 @@
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^4.18.2", "express": "^4.18.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"mqtt": "^5.16.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-stl": "^0.7.0", "node-stl": "^0.7.0",
"nodemailer": "^9.0.1", "nodemailer": "^9.0.1",
@ -29,6 +30,15 @@
"nodemon": "^3.0.2" "nodemon": "^3.0.2"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.11.3", "version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
@ -603,6 +613,24 @@
"undici-types": "~8.9.0" "undici-types": "~8.9.0"
} }
}, },
"node_modules/@types/readable-stream": {
"version": "4.0.24",
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.24.tgz",
"integrity": "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/7z-wasm": { "node_modules/7z-wasm": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/7z-wasm/-/7z-wasm-1.2.0.tgz", "resolved": "https://registry.npmjs.org/7z-wasm/-/7z-wasm-1.2.0.tgz",
@ -618,6 +646,18 @@
"node": ">=8.0.0" "node": ">=8.0.0"
} }
}, },
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@ -676,6 +716,26 @@
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
} }
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bcryptjs": { "node_modules/bcryptjs": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
@ -698,6 +758,43 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/bl": {
"version": "6.1.6",
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
"integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==",
"license": "MIT",
"dependencies": {
"@types/readable-stream": "^4.0.0",
"buffer": "^6.0.3",
"inherits": "^2.0.4",
"readable-stream": "^4.2.0"
}
},
"node_modules/bl/node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/bl/node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.5", "version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
@ -755,6 +852,42 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/broker-factory": {
"version": "3.1.15",
"resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.15.tgz",
"integrity": "sha512-ko+aWvgNuP49meGrdjUu7rC+Y+Wai3cCPxP3xWwHsHfehFjOh5ZQM2yC4gEB2UddeZ/YXhm0K1eG/L6fxym2Og==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
"fast-unique-numbers": "^9.0.27",
"tslib": "^2.8.1",
"worker-factory": "^7.0.50"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-equal-constant-time": { "node_modules/buffer-equal-constant-time": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@ -850,6 +983,12 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/commist": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz",
"integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==",
"license": "MIT"
},
"node_modules/concat-stream": { "node_modules/concat-stream": {
"version": "1.6.2", "version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
@ -1116,6 +1255,24 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/express": { "node_modules/express": {
"version": "4.22.2", "version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
@ -1162,6 +1319,19 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/fast-unique-numbers": {
"version": "9.0.27",
"resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.27.tgz",
"integrity": "sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=18.2.0"
}
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -1388,6 +1558,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/help-me": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
"license": "MIT"
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@ -1420,6 +1596,26 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore-by-default": { "node_modules/ignore-by-default": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
@ -1433,6 +1629,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip-address": {
"version": "10.7.2",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
"integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -1597,6 +1802,12 @@
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/matcher": { "node_modules/matcher": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
@ -1736,6 +1947,148 @@
"mkdirp": "bin/cmd.js" "mkdirp": "bin/cmd.js"
} }
}, },
"node_modules/mqtt": {
"version": "5.16.0",
"resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.16.0.tgz",
"integrity": "sha512-G0VsEtuJz5sJUgZJaaADMVfW7SIFPzkIuHR+d6rk+tBeSwdCfd9BaIRycRfd2AbyFDEmLlFM+SDQeti/+IbOnw==",
"license": "MIT",
"dependencies": {
"@types/readable-stream": "^4.0.21",
"@types/ws": "^8.18.1",
"commist": "^3.2.0",
"concat-stream": "^2.0.0",
"debug": "^4.4.1",
"help-me": "^5.0.0",
"lru-cache": "^10.4.3",
"minimist": "^1.2.8",
"mqtt-packet": "^9.0.2",
"readable-stream": "^4.7.0",
"rfdc": "^1.4.1",
"socks": "^2.8.6",
"split2": "^4.2.0",
"worker-timers": "^8.0.23",
"ws": "^8.18.3"
},
"bin": {
"mqtt": "build/bin/mqtt.js",
"mqtt_pub": "build/bin/pub.js",
"mqtt_sub": "build/bin/sub.js"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/mqtt-packet": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz",
"integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==",
"license": "MIT",
"dependencies": {
"bl": "^6.0.8",
"debug": "^4.3.4",
"process-nextick-args": "^2.0.1"
}
},
"node_modules/mqtt-packet/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/mqtt-packet/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/mqtt/node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/mqtt/node_modules/concat-stream/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/mqtt/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/mqtt/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/mqtt/node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/mqtt/node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@ -2102,6 +2455,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/process-nextick-args": { "node_modules/process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@ -2233,6 +2595,12 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/rhino3dm": { "node_modules/rhino3dm": {
"version": "8.32.2", "version": "8.32.2",
"resolved": "https://registry.npmjs.org/rhino3dm/-/rhino3dm-8.32.2.tgz", "resolved": "https://registry.npmjs.org/rhino3dm/-/rhino3dm-8.32.2.tgz",
@ -2495,6 +2863,30 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.10",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz",
"integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/split2": { "node_modules/split2": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
@ -2607,8 +2999,7 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD", "license": "0BSD"
"optional": true
}, },
"node_modules/type-fest": { "node_modules/type-fest": {
"version": "0.13.1", "version": "0.13.1",
@ -2687,6 +3078,74 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/worker-factory": {
"version": "7.0.50",
"resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.50.tgz",
"integrity": "sha512-hhwc0G+sFwM4qBuhJIUBn2p1Jf8v/FwmLUANBf/Q+Lt2uI8mfIZQhXaZQACodQD4R7Zp6cn/6702bIvNn2puJQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
"fast-unique-numbers": "^9.0.27",
"tslib": "^2.8.1"
}
},
"node_modules/worker-timers": {
"version": "8.0.34",
"resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.34.tgz",
"integrity": "sha512-WXL+Dqsm0G6dnC66rQsvM3tPT2adhbqSirWUCZGglkALOr8ocS0KlpU0iuKbjflJOlu3ydZikMEVrYnHOM9suw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
"tslib": "^2.8.1",
"worker-timers-broker": "^8.0.18",
"worker-timers-worker": "^9.0.15"
}
},
"node_modules/worker-timers-broker": {
"version": "8.0.18",
"resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.18.tgz",
"integrity": "sha512-FrjzDVX1wKfZN0gRbCFqv8VHuTncG4sbI/WGEg4tSSQeIsnwqg4YBYWMAHYLJtUDEYYmiK65UKFrVMVk2irDSg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
"broker-factory": "^3.1.15",
"fast-unique-numbers": "^9.0.27",
"tslib": "^2.8.1",
"worker-timers-worker": "^9.0.15"
}
},
"node_modules/worker-timers-worker": {
"version": "9.0.15",
"resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.15.tgz",
"integrity": "sha512-KKUe7lZ/Aignr51H6hOUik8LwTnIgojH/1lwhli8A8qIEIyewogZTpNpMW5B6BF7nmwBOkUoTYgf1H3QShcjSA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
"tslib": "^2.8.1",
"worker-factory": "^7.0.50"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xtend": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View file

@ -25,6 +25,7 @@
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^4.18.2", "express": "^4.18.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"mqtt": "^5.16.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-stl": "^0.7.0", "node-stl": "^0.7.0",
"nodemailer": "^9.0.1", "nodemailer": "^9.0.1",

View file

@ -905,6 +905,7 @@ function setupEventListeners() {
if (e.target.closest('#undoRenameBtn')) handleUndoRename(); if (e.target.closest('#undoRenameBtn')) handleUndoRename();
}); });
document.getElementById('aiTestBtn').addEventListener('click', handleAiTest); document.getElementById('aiTestBtn').addEventListener('click', handleAiTest);
document.getElementById('printerTestBtn').addEventListener('click', handlePrinterTest);
document.getElementById('aiProvider').addEventListener('change', () => { document.getElementById('aiProvider').addEventListener('change', () => {
// A new provider starts from its own defaults. // A new provider starts from its own defaults.
['aiModel', 'aiBaseUrl', 'aiPriceInput', 'aiPriceOutput'].forEach((id) => { document.getElementById(id).value = ''; }); ['aiModel', 'aiBaseUrl', 'aiPriceInput', 'aiPriceOutput'].forEach((id) => { document.getElementById(id).value = ''; });
@ -1462,6 +1463,41 @@ async function handleAiTest() {
} }
} }
// Saves the printer settings, then listens to the printer for a few seconds and
// says what it reported. Only reads; the printer is never sent a command.
async function handlePrinterTest() {
const out = document.getElementById('printerTestResult');
const btn = document.getElementById('printerTestBtn');
out.className = 'form-hint ai-test-result';
out.textContent = 'Saving and listening to the printer for a few seconds…';
btn.disabled = true;
const trayHtml = (t) => `<span class="spool-dot" style="background:${escapeHtml(t.colour || '#888888')}"></span>
${escapeHtml(t.material || '?')}${t.remain != null ? ` ${t.remain}%` : ''}`;
try {
await postJson('/api/settings', settingsPayload(), 'PUT');
const r = await postJson('/api/printer/test');
if (!r.connected) throw new Error(r.error || 'Couldn’t connect to the printer.');
const lines = [`✓ Connected in ${(r.ms / 1000).toFixed(1)} s; ${plural(r.reports, 'report')} received.`];
if (r.printer && r.printer.state) lines.push(`The printer is ${escapeHtml(String(r.printer.state).toLowerCase())}.`);
if (r.error) lines.push(`But: ${escapeHtml(r.error)}`);
const loaded = (r.ams || []).filter((t) => !t.empty);
if (r.ams && r.ams.length) {
lines.push(`AMS: ${loaded.length} of ${r.ams.length} slots loaded${loaded.length ? ` — ${loaded.map(trayHtml).join(', ')}` : ''}.`);
}
if (r.external && !r.external.empty) lines.push(`External spool: ${trayHtml(r.external)}.`);
if (!r.reports) {
lines.push('But it sent nothing back — in cloud mode the printer may be refusing the request for a full report.');
}
out.classList.add(r.reports ? 'is-ok' : 'is-error');
out.innerHTML = lines.join(' ');
} catch (err) {
out.classList.add('is-error');
out.textContent = `✗ ${err.message}`;
} finally {
btn.disabled = false;
}
}
// Tags the open model on its own, then refreshes its tags and description. // Tags the open model on its own, then refreshes its tags and description.
async function handleAiTagModel() { async function handleAiTagModel() {
const id = Number(fileDetailModal.dataset.currentModelId); const id = Number(fileDetailModal.dataset.currentModelId);

View file

@ -300,6 +300,33 @@
</div> </div>
<div id="aiTestResult" class="form-hint ai-test-result"></div> <div id="aiTestResult" class="form-hint ai-test-result"></div>
<h3 class="settings-group-title" id="printerSettings">Printer</h3>
<div class="ai-grid">
<div class="form-group">
<label for="printerHost">Printer address</label>
<input type="text" id="printerHost" data-setting="printer_host" data-clear-blank placeholder="192.168.1.50" autocomplete="off" spellcheck="false">
</div>
<div class="form-group">
<label for="printerSerial">Serial number</label>
<input type="text" id="printerSerial" data-setting="printer_serial" data-clear-blank autocomplete="off" spellcheck="false">
</div>
<div class="form-group">
<label for="printerAccessCode">Access code</label>
<input type="password" id="printerAccessCode" data-setting="printer_access_code" placeholder="From the printer’s screen" autocomplete="off">
</div>
</div>
<p class="form-hint">
Access code: <span class="setting-status" data-status="printer_access_code">—</span>.
Bambu Lab printers show it in their network settings, and the serial number is on the screen and the sticker.
Printhive only reads what the printer reports — filament loaded, progress — and never sends it commands.
The code is stored on your server.
</p>
<div class="ai-test-row">
<button type="button" class="btn btn-secondary btn-sm" id="printerTestBtn">Save &amp; test connection</button>
<button type="button" class="btn btn-link clear-setting" data-setting="printer_access_code">Clear code</button>
</div>
<div id="printerTestResult" class="form-hint ai-test-result"></div>
<h3 class="settings-group-title">Import sources</h3> <h3 class="settings-group-title">Import sources</h3>
<div class="form-group"> <div class="form-group">

View file

@ -30,6 +30,7 @@ const collectionWatch = require('./lib/collectionWatch');
const autotag = require('./lib/autotag'); const autotag = require('./lib/autotag');
const filaments = require('./lib/filaments'); const filaments = require('./lib/filaments');
const filamentPalette = require('./db/bambu-filaments.json'); const filamentPalette = require('./db/bambu-filaments.json');
const printer = require('./lib/printer');
const embeddings = require('./lib/embeddings'); const embeddings = require('./lib/embeddings');
const app = express(); const app = express();
@ -122,6 +123,9 @@ const SETTINGS = {
ai_send_images: { env: 'AI_SEND_IMAGES', label: 'Send thumbnails to the AI', secret: false }, ai_send_images: { env: 'AI_SEND_IMAGES', label: 'Send thumbnails to the AI', secret: false },
ai_price_input: { env: 'AI_PRICE_INPUT', label: 'Input price ($ per million tokens)', secret: false }, ai_price_input: { env: 'AI_PRICE_INPUT', label: 'Input price ($ per million tokens)', secret: false },
ai_price_output: { env: 'AI_PRICE_OUTPUT', label: 'Output price ($ per million tokens)', secret: false }, ai_price_output: { env: 'AI_PRICE_OUTPUT', label: 'Output price ($ per million tokens)', secret: false },
printer_host: { env: 'PRINTER_HOST', label: 'Printer address', secret: false },
printer_serial: { env: 'PRINTER_SERIAL', label: 'Printer serial number', secret: false },
printer_access_code: { env: 'PRINTER_ACCESS_CODE', label: 'Printer access code', secret: true },
}; };
const AI_SETTING_KEYS = Object.keys(SETTINGS).filter((k) => k.startsWith('ai_')); const AI_SETTING_KEYS = Object.keys(SETTINGS).filter((k) => k.startsWith('ai_'));
@ -324,6 +328,19 @@ app.put('/api/settings', async (req, res, next) => {
// ----------------------------------------------------------------- filament // ----------------------------------------------------------------- filament
// The spools you have. What a model needs is read from its 3MF in the // The spools you have. What a model needs is read from its 3MF in the
// background and kept on the model itself, so matching is a plain comparison. // background and kept on the model itself, so matching is a plain comparison.
// Tries the printer connection with the saved settings and says what came back.
// Read-only, and the access code never leaves the server.
app.post('/api/printer/test', async (req, res, next) => {
try {
const host = (await resolveSetting('printer_host')).value;
const serial = (await resolveSetting('printer_serial')).value;
const accessCode = (await resolveSetting('printer_access_code')).value;
const missing = [!host && 'address', !serial && 'serial number', !accessCode && 'access code'].filter(Boolean);
if (missing.length) return res.status(400).json({ error: `Fill in the printer’s ${missing.join(', ')} first.` });
res.json(await printer.probe({ host: host.trim(), serial: serial.trim(), accessCode: accessCode.trim() }));
} catch (e) { next(e); }
});
// The maker's filament list, for picking a spool without typing hex codes. // The maker's filament list, for picking a spool without typing hex codes.
// Static, so it's cached hard rather than re-read. // Static, so it's cached hard rather than re-read.
app.get('/api/filament/palette', (req, res) => { app.get('/api/filament/palette', (req, res) => {

43
test/printer.test.js Normal file
View file

@ -0,0 +1,43 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const printer = require('../lib/printer');
test('AMS trays are read into material, colour and how much is left', () => {
const trays = printer.describeAms([{ id: '0', tray: [
{ id: '0', tray_type: 'PLA', tray_color: 'FFFFFFFF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', remain: 82 },
{ id: '1', tray_type: '', tray_color: '00000000' }, // empty slot
{ id: '2', tray_type: 'PETG', tray_color: '000000FF', remain: -1 }, // not a Bambu spool
] }]);
assert.deepEqual(trays[0], {
unit: 0, slot: 0, empty: false, material: 'PLA', colour: '#FFFFFF',
profile: 'GFA00', brand: 'PLA Basic', remain: 82,
});
assert.equal(trays[1].empty, true);
assert.equal(trays[1].material, null);
assert.equal(trays[1].colour, null, 'an empty slot has no colour, whatever the printer says');
assert.equal(trays[2].remain, null, '-1 means the printer can’t tell');
assert.equal(trays[2].colour, '#000000', 'the alpha byte is dropped');
});
test('a printer with no AMS reports nothing rather than failing', () => {
assert.deepEqual(printer.describeAms(undefined), []);
assert.deepEqual(printer.describeAms([{ id: '0' }]), []);
});
test('the external spool holder is read the same way as a tray', () => {
const ext = printer.describeTray({ id: '254', tray_type: 'PLA', tray_color: 'C12E1FFF', remain: 40 });
assert.equal(ext.unit, null);
assert.equal(ext.material, 'PLA');
assert.equal(ext.colour, '#C12E1F');
assert.equal(ext.remain, 40);
});
test('connection failures are explained in plain words', () => {
assert.equal(printer.friendlyError(new Error('Connection refused: Not authorized')),
'The printer refused the access code.');
assert.equal(printer.friendlyError(new Error('connect ECONNREFUSED 192.0.2.10:8883')),
'Nothing answered on port 8883 at that address.');
assert.equal(printer.friendlyError(new Error('connect EHOSTUNREACH 192.0.2.10:8883')),
'Couldn’t reach the printer from the server.');
assert.equal(printer.friendlyError(null), 'The connection failed.');
});