Compare commits
No commits in common. "697ac0ccc8c0a45dff03a3778acb40b9be1ec842" and "1c19ac565e094846f0ef5ba225fe601178c5f6cc" have entirely different histories.
697ac0ccc8
...
1c19ac565e
11 changed files with 122 additions and 646 deletions
|
|
@ -28,7 +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.
|
||||||
- **Printers** — add as many Bambu Lab printers as you have on the Printers page, each with an optional nickname (the model is shown when it's blank), a model picked from the lineup with a drawing of its shape, its AMS type, and its address, serial number and access code. "Test & sync" listens to a printer for a few seconds and reports its state and the filament in each AMS slot or on the external holder. The connection is read-only — Printhive never sends a printer a command — and access codes stay on your server. Spools a printer reads from their RFID tags are added to the Filament page by themselves and kept up to date every ten minutes: named and coded from Bambu's filament list, shown with their tag ID, matched to any you'd already added by hand rather than duplicated, and marked as taken out when they leave the printer. Grams left are filled in from an AMS, AMS 2 Pro or AMS HT; an AMS lite only knows full or empty, so those are left to you. Spools without a tag can't be told apart from one another, so they're listed for you to add by hand.
|
- **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. Spools the printer reads from their RFID tags are added to the Filament page by themselves and kept up to date every ten minutes: named and coded from Bambu's filament list, matched to any you'd already added by hand rather than duplicated, and marked as taken out when they leave the printer. Spools without a tag can't be told apart from one another, so they're listed for you to add by hand.
|
||||||
- **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.
|
||||||
|
|
||||||
|
|
@ -73,10 +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 |
|
||||||
Printers are added on the **Printers** page instead — as many as you like, each with a nickname, model, AMS type,
|
| Printer serial number | `PRINTER_SERIAL` | Shown on the printer's screen and sticker |
|
||||||
address, serial number and access code. Access codes stay on the server. A printer set up in Settings by an earlier
|
| Printer access code | `PRINTER_ACCESS_CODE` | From the printer's network settings; kept on the server |
|
||||||
version moves there automatically; the old `PRINTER_*` environment variables are no longer read.
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -297,36 +297,3 @@ ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS product_code TEXT;
|
||||||
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS printer_serial TEXT;
|
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS printer_serial TEXT;
|
||||||
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ;
|
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ;
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS filament_spools_rfid ON filament_spools (rfid_uuid) WHERE rfid_uuid IS NOT NULL;
|
CREATE UNIQUE INDEX IF NOT EXISTS filament_spools_rfid ON filament_spools (rfid_uuid) WHERE rfid_uuid IS NOT NULL;
|
||||||
|
|
||||||
-- ------------------------------------------------------------------ printers
|
|
||||||
-- The printers you've added. access_code is read by the server only.
|
|
||||||
CREATE TABLE IF NOT EXISTS printers (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
nickname TEXT, -- shown instead of the model when set
|
|
||||||
model TEXT, -- A1, P1S, … or Other
|
|
||||||
host TEXT,
|
|
||||||
serial TEXT,
|
|
||||||
access_code TEXT,
|
|
||||||
ams_type TEXT, -- none | ams_lite | ams | ams_2_pro | ams_ht
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
date_modified TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
|
|
||||||
-- The one printer that used to live in Settings becomes the first entry here.
|
|
||||||
INSERT INTO printers (host, serial, access_code)
|
|
||||||
SELECT h.value, s.value, c.value
|
|
||||||
FROM settings h
|
|
||||||
JOIN settings s ON s.key = 'printer_serial'
|
|
||||||
JOIN settings c ON c.key = 'printer_access_code'
|
|
||||||
WHERE h.key = 'printer_host'
|
|
||||||
AND coalesce(h.value, '') <> '' AND coalesce(s.value, '') <> '' AND coalesce(c.value, '') <> ''
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM printers);
|
|
||||||
|
|
||||||
-- …and its copy in Settings goes, so the access code isn't kept twice — but
|
|
||||||
-- only once that printer is safely here.
|
|
||||||
DELETE FROM settings
|
|
||||||
WHERE key IN ('printer_host', 'printer_serial', 'printer_access_code')
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM printers p
|
|
||||||
JOIN settings s ON s.key = 'printer_serial' AND s.value = p.serial
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
// The printers you've added.
|
|
||||||
//
|
|
||||||
// Access codes are stored here but never handed back to the browser: every
|
|
||||||
// read returns `codeSet` instead, and saving with a blank code keeps the one
|
|
||||||
// already stored.
|
|
||||||
const db = require('./db');
|
|
||||||
|
|
||||||
// Which AMS types can say how much filament is left. An AMS lite only knows
|
|
||||||
// full or empty, so its reading is never used as a weight.
|
|
||||||
const AMS_TYPES = { none: false, ams_lite: false, ams: true, ams_2_pro: true, ams_ht: true };
|
|
||||||
|
|
||||||
const clean = (value, max) => {
|
|
||||||
const s = String(value ?? '').trim();
|
|
||||||
return s ? s.slice(0, max) : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function publicPrinter(row) {
|
|
||||||
if (!row) return null;
|
|
||||||
const { access_code: accessCode, ...rest } = row;
|
|
||||||
return { ...rest, codeSet: Boolean(accessCode) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function fields(data) {
|
|
||||||
return {
|
|
||||||
nickname: clean(data.nickname, 60),
|
|
||||||
model: clean(data.model, 40),
|
|
||||||
host: clean(data.host, 255),
|
|
||||||
serial: clean(data.serial, 40),
|
|
||||||
ams_type: Object.prototype.hasOwnProperty.call(AMS_TYPES, data.ams_type) ? data.ams_type : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function list() {
|
|
||||||
const { rows } = await db.query('SELECT * FROM printers ORDER BY id');
|
|
||||||
return rows.map(publicPrinter);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server-side only: includes the access code, for connecting.
|
|
||||||
async function listWithCodes() {
|
|
||||||
const { rows } = await db.query('SELECT * FROM printers ORDER BY id');
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function get(id, { withCode = false } = {}) {
|
|
||||||
const { rows: [row] } = await db.query('SELECT * FROM printers WHERE id = $1', [id]);
|
|
||||||
if (!row) return null;
|
|
||||||
return withCode ? row : publicPrinter(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function create(data) {
|
|
||||||
const f = fields(data);
|
|
||||||
const { rows: [row] } = await db.query(
|
|
||||||
`INSERT INTO printers (nickname, model, host, serial, access_code, ams_type)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
|
||||||
[f.nickname, f.model, f.host, f.serial, clean(data.access_code, 64), f.ams_type]);
|
|
||||||
return publicPrinter(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only the fields sent are changed; a blank access code keeps the stored one.
|
|
||||||
async function update(id, data) {
|
|
||||||
const f = fields(data);
|
|
||||||
const has = (key) => Object.prototype.hasOwnProperty.call(data, key);
|
|
||||||
const { rows: [row] } = await db.query(
|
|
||||||
`UPDATE printers SET
|
|
||||||
nickname = CASE WHEN $2::boolean THEN $3 ELSE nickname END,
|
|
||||||
model = CASE WHEN $4::boolean THEN $5 ELSE model END,
|
|
||||||
host = CASE WHEN $6::boolean THEN $7 ELSE host END,
|
|
||||||
serial = CASE WHEN $8::boolean THEN $9 ELSE serial END,
|
|
||||||
ams_type = CASE WHEN $10::boolean THEN $11 ELSE ams_type END,
|
|
||||||
access_code = COALESCE($12, access_code),
|
|
||||||
date_modified = now()
|
|
||||||
WHERE id = $1 RETURNING *`,
|
|
||||||
[id, has('nickname'), f.nickname, has('model'), f.model, has('host'), f.host,
|
|
||||||
has('serial'), f.serial, has('ams_type'), f.ams_type, clean(data.access_code, 64)]);
|
|
||||||
return publicPrinter(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Removing a printer also marks its spools as no longer loaded anywhere.
|
|
||||||
async function remove(id) {
|
|
||||||
const { rows: [row] } = await db.query('DELETE FROM printers WHERE id = $1 RETURNING serial', [id]);
|
|
||||||
if (!row) return false;
|
|
||||||
if (row.serial) {
|
|
||||||
await db.query('UPDATE filament_spools SET ams_slot = NULL WHERE printer_serial = $1', [row.serial]);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const measures = (amsType) => Boolean(AMS_TYPES[amsType]);
|
|
||||||
|
|
||||||
module.exports = { AMS_TYPES, list, listWithCodes, get, create, update, remove, measures, publicPrinter };
|
|
||||||
|
|
@ -15,13 +15,8 @@ const ADOPT_TOLERANCE = 12;
|
||||||
const slotKey = (printerSerial, tray) =>
|
const slotKey = (printerSerial, tray) =>
|
||||||
`${printerSerial}/${tray.unit == null ? 'ext' : tray.unit}/${tray.slot == null ? 0 : tray.slot}`;
|
`${printerSerial}/${tray.unit == null ? 'ext' : tray.unit}/${tray.slot == null ? 0 : tray.slot}`;
|
||||||
|
|
||||||
function planSync(existing, trays, printerSerial, palette, { measures = false } = {}) {
|
function planSync(existing, trays, printerSerial, palette) {
|
||||||
const actions = [];
|
const actions = [];
|
||||||
// Grams left, only when this printer's AMS actually measures it. An AMS lite
|
|
||||||
// reports full or empty, which is not a weight.
|
|
||||||
const gramsLeft = (tray) => (measures && tray.remain != null && tray.weight
|
|
||||||
? Math.round((tray.weight * tray.remain) / 100)
|
|
||||||
: null);
|
|
||||||
const untracked = [];
|
const untracked = [];
|
||||||
const byUuid = new Map(existing.filter((s) => s.rfid_uuid).map((s) => [s.rfid_uuid, s]));
|
const byUuid = new Map(existing.filter((s) => s.rfid_uuid).map((s) => [s.rfid_uuid, s]));
|
||||||
const adopted = new Set(); // hand-added spools already matched in this report
|
const adopted = new Set(); // hand-added spools already matched in this report
|
||||||
|
|
@ -38,7 +33,7 @@ function planSync(existing, trays, printerSerial, palette, { measures = false }
|
||||||
const known = byUuid.get(tray.uuid);
|
const known = byUuid.get(tray.uuid);
|
||||||
if (known) {
|
if (known) {
|
||||||
seen.add(known.id);
|
seen.add(known.id);
|
||||||
actions.push({ op: 'update', id: known.id, slot, printerSerial, remaining: gramsLeft(tray) });
|
actions.push({ op: 'update', id: known.id, slot, printerSerial });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,7 +53,7 @@ function planSync(existing, trays, printerSerial, palette, { measures = false }
|
||||||
actions.push({
|
actions.push({
|
||||||
op: 'adopt', id: adopt.id, slot, printerSerial,
|
op: 'adopt', id: adopt.id, slot, printerSerial,
|
||||||
uuid: tray.uuid, tagUid: tray.tagUid, productCode: tray.idName,
|
uuid: tray.uuid, tagUid: tray.tagUid, productCode: tray.idName,
|
||||||
code: named ? named.code : null, totalG: tray.weight, remaining: gramsLeft(tray),
|
code: named ? named.code : null, totalG: tray.weight,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -72,7 +67,7 @@ function planSync(existing, trays, printerSerial, palette, { measures = false }
|
||||||
brand: 'Bambu Lab',
|
brand: 'Bambu Lab',
|
||||||
code: named ? named.code : null,
|
code: named ? named.code : null,
|
||||||
total_g: tray.weight,
|
total_g: tray.weight,
|
||||||
remaining_g: gramsLeft(tray), // blank unless this printer's AMS measures it
|
remaining_g: null, // an AMS lite only knows full or empty
|
||||||
rfid_uuid: tray.uuid,
|
rfid_uuid: tray.uuid,
|
||||||
tag_uid: tray.tagUid,
|
tag_uid: tray.tagUid,
|
||||||
product_code: tray.idName,
|
product_code: tray.idName,
|
||||||
|
|
@ -106,18 +101,14 @@ async function applySync({ actions }) {
|
||||||
const { rowCount } = await db.query(
|
const { rowCount } = await db.query(
|
||||||
`UPDATE filament_spools SET rfid_uuid = $2, tag_uid = $3, product_code = $4,
|
`UPDATE filament_spools SET rfid_uuid = $2, tag_uid = $3, product_code = $4,
|
||||||
code = COALESCE(code, $5), total_g = COALESCE(total_g, $6),
|
code = COALESCE(code, $5), total_g = COALESCE(total_g, $6),
|
||||||
ams_slot = $7, printer_serial = $8, remaining_g = COALESCE($9, remaining_g),
|
ams_slot = $7, printer_serial = $8, last_seen_at = now(), date_modified = now()
|
||||||
last_seen_at = now(), date_modified = now()
|
|
||||||
WHERE id = $1 AND rfid_uuid IS NULL`,
|
WHERE id = $1 AND rfid_uuid IS NULL`,
|
||||||
[a.id, a.uuid, a.tagUid, a.productCode, a.code, a.totalG, a.slot, a.printerSerial, a.remaining ?? null]);
|
[a.id, a.uuid, a.tagUid, a.productCode, a.code, a.totalG, a.slot, a.printerSerial]);
|
||||||
summary.adopted += rowCount;
|
summary.adopted += rowCount;
|
||||||
} else if (a.op === 'update') {
|
} else if (a.op === 'update') {
|
||||||
// A measured weight replaces the stored one; no reading leaves it alone.
|
|
||||||
await db.query(
|
await db.query(
|
||||||
`UPDATE filament_spools SET ams_slot = $2, printer_serial = $3, last_seen_at = now(),
|
'UPDATE filament_spools SET ams_slot = $2, printer_serial = $3, last_seen_at = now() WHERE id = $1',
|
||||||
remaining_g = COALESCE($4, remaining_g)
|
[a.id, a.slot, a.printerSerial]);
|
||||||
WHERE id = $1`,
|
|
||||||
[a.id, a.slot, a.printerSerial, a.remaining ?? null]);
|
|
||||||
summary.updated += 1;
|
summary.updated += 1;
|
||||||
} else if (a.op === 'unload') {
|
} else if (a.op === 'unload') {
|
||||||
await db.query('UPDATE filament_spools SET ams_slot = NULL WHERE id = $1', [a.id]);
|
await db.query('UPDATE filament_spools SET ams_slot = NULL WHERE id = $1', [a.id]);
|
||||||
|
|
@ -128,9 +119,9 @@ async function applySync({ actions }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reads the shelf, plans against one printer's trays, and applies it.
|
// Reads the shelf, plans against one printer's trays, and applies it.
|
||||||
async function syncPrinter(printerSerial, trays, palette, options = {}) {
|
async function syncPrinter(printerSerial, trays, palette) {
|
||||||
const { rows: existing } = await db.query('SELECT * FROM filament_spools');
|
const { rows: existing } = await db.query('SELECT * FROM filament_spools');
|
||||||
const plan = planSync(existing, trays, printerSerial, palette, options);
|
const plan = planSync(existing, trays, printerSerial, palette);
|
||||||
const summary = await applySync(plan);
|
const summary = await applySync(plan);
|
||||||
return { ...summary, untracked: plan.untracked };
|
return { ...summary, untracked: plan.untracked };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
322
public/app.js
322
public/app.js
|
|
@ -6,7 +6,6 @@ let config = { importers: {} };
|
||||||
let stats = { trash: 0, duplicateGroups: 0, fingerprintsPending: 0 };
|
let stats = { trash: 0, duplicateGroups: 0, fingerprintsPending: 0 };
|
||||||
let selectedScope = 'all'; // 'all' | 'duplicates' | 'trash' | a collection id
|
let selectedScope = 'all'; // 'all' | 'duplicates' | 'trash' | a collection id
|
||||||
let spools = []; // filament on hand, so cards can say what's printable
|
let spools = []; // filament on hand, so cards can say what's printable
|
||||||
let printerList = []; // your printers, so spools can say which one they're in
|
|
||||||
let palette = []; // the maker's filament list: names, codes and colours
|
let palette = []; // the maker's filament list: names, codes and colours
|
||||||
let tagFilters = {}; // tag -> 'include' | 'exclude'
|
let tagFilters = {}; // tag -> 'include' | 'exclude'
|
||||||
let duplicateData = null; // last /api/duplicates response
|
let duplicateData = null; // last /api/duplicates response
|
||||||
|
|
@ -17,7 +16,7 @@ const prefs = { view: 'grid', tagMatch: 'any', format: '', source: '', license:
|
||||||
const PREFS_KEY = 'printhive.prefs';
|
const PREFS_KEY = 'printhive.prefs';
|
||||||
// Scopes that aren't a collection id. Leaving one out means loadData() decides
|
// Scopes that aren't a collection id. Leaving one out means loadData() decides
|
||||||
// the collection vanished and drops you back to All Models on the next refresh.
|
// the collection vanished and drops you back to All Models on the next refresh.
|
||||||
const SPECIAL_SCOPES = ['all', 'duplicates', 'trash', 'ai', 'watch', 'filament', 'printers'];
|
const SPECIAL_SCOPES = ['all', 'duplicates', 'trash', 'ai', 'watch', 'filament'];
|
||||||
|
|
||||||
// DOM
|
// DOM
|
||||||
const fileGrid = document.getElementById('fileGrid');
|
const fileGrid = document.getElementById('fileGrid');
|
||||||
|
|
@ -149,21 +148,18 @@ async function handleLogout() {
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
try {
|
try {
|
||||||
const [modelsRes, projectsRes, configRes, statsRes, filamentRes, printersRes] = await Promise.all([
|
const [modelsRes, projectsRes, configRes, statsRes, filamentRes] = await Promise.all([
|
||||||
fetch('/api/models'),
|
fetch('/api/models'),
|
||||||
fetch('/api/projects'),
|
fetch('/api/projects'),
|
||||||
fetch('/api/config'),
|
fetch('/api/config'),
|
||||||
fetch('/api/library/stats'),
|
fetch('/api/library/stats'),
|
||||||
fetch('/api/filament'),
|
fetch('/api/filament'),
|
||||||
fetch('/api/printers'),
|
|
||||||
]);
|
]);
|
||||||
models = await modelsRes.json();
|
models = await modelsRes.json();
|
||||||
projects = await projectsRes.json();
|
projects = await projectsRes.json();
|
||||||
config = await configRes.json();
|
config = await configRes.json();
|
||||||
stats = statsRes.ok ? await statsRes.json() : stats;
|
stats = statsRes.ok ? await statsRes.json() : stats;
|
||||||
spools = filamentRes.ok ? (await filamentRes.json()).spools : [];
|
spools = filamentRes.ok ? (await filamentRes.json()).spools : [];
|
||||||
printerList = printersRes.ok ? await printersRes.json() : [];
|
|
||||||
setCount('printerCount', printerList.length);
|
|
||||||
// Static and ~40KB, so fetched once rather than on every refresh.
|
// Static and ~40KB, so fetched once rather than on every refresh.
|
||||||
if (!palette.length) {
|
if (!palette.length) {
|
||||||
palette = await fetch('/api/filament/palette')
|
palette = await fetch('/api/filament/palette')
|
||||||
|
|
@ -454,14 +450,13 @@ function scopeTitle() {
|
||||||
if (selectedScope === 'ai') return 'AI & automatic tags';
|
if (selectedScope === 'ai') return 'AI & automatic tags';
|
||||||
if (selectedScope === 'watch') return 'Watched collections';
|
if (selectedScope === 'watch') return 'Watched collections';
|
||||||
if (selectedScope === 'filament') return 'Filament';
|
if (selectedScope === 'filament') return 'Filament';
|
||||||
if (selectedScope === 'printers') return 'Printers';
|
|
||||||
const p = projects.find((x) => String(x.id) === String(selectedScope));
|
const p = projects.find((x) => String(x.id) === String(selectedScope));
|
||||||
return p ? p.name : 'Collection';
|
return p ? p.name : 'Collection';
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateScopeChrome() {
|
function updateScopeChrome() {
|
||||||
contentTitle.textContent = scopeTitle();
|
contentTitle.textContent = scopeTitle();
|
||||||
const panel = ['duplicates', 'ai', 'watch', 'printers'].includes(selectedScope); // pages without the model grid
|
const panel = ['duplicates', 'ai', 'watch'].includes(selectedScope); // pages without the model grid
|
||||||
document.getElementById('filterBar').hidden = panel;
|
document.getElementById('filterBar').hidden = panel;
|
||||||
document.querySelector('.view-options').hidden = panel;
|
document.querySelector('.view-options').hidden = panel;
|
||||||
emptyTrashBtn.hidden = !(selectedScope === 'trash' && trashModels.length);
|
emptyTrashBtn.hidden = !(selectedScope === 'trash' && trashModels.length);
|
||||||
|
|
@ -479,7 +474,6 @@ function renderModels() {
|
||||||
clearTimeout(aiPoll);
|
clearTimeout(aiPoll);
|
||||||
if (selectedScope === 'watch') { renderWatchPanel(); return; }
|
if (selectedScope === 'watch') { renderWatchPanel(); return; }
|
||||||
if (selectedScope === 'filament') { renderFilamentPanel(); return; }
|
if (selectedScope === 'filament') { renderFilamentPanel(); return; }
|
||||||
if (selectedScope === 'printers') { renderPrintersPanel(); return; }
|
|
||||||
clearTimeout(watchPoll);
|
clearTimeout(watchPoll);
|
||||||
|
|
||||||
const list = getFilteredModels();
|
const list = getFilteredModels();
|
||||||
|
|
@ -911,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 = ''; });
|
||||||
|
|
@ -1468,6 +1463,52 @@ 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.sync) {
|
||||||
|
const s = r.sync;
|
||||||
|
const parts = [];
|
||||||
|
if (s.created) parts.push(`${plural(s.created, 'spool')} added`);
|
||||||
|
if (s.adopted) parts.push(`${plural(s.adopted, 'spool')} matched to ones you’d added`);
|
||||||
|
if (s.updated) parts.push(`${plural(s.updated, 'spool')} already known`);
|
||||||
|
if (s.unloaded) parts.push(`${plural(s.unloaded, 'spool')} marked as taken out`);
|
||||||
|
if (parts.length) lines.push(`Filament page: ${parts.join(', ')}.`);
|
||||||
|
const n = (s.untracked || []).length;
|
||||||
|
if (n) lines.push(`${plural(n, 'spool')} without an RFID tag ${n === 1 ? 'isn’t' : 'aren’t'} tracked automatically — add ${n === 1 ? 'it' : 'them'} by hand.`);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
|
@ -2443,25 +2484,16 @@ function filamentPanelHtml() {
|
||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// "<printer serial>/<unit>/<slot>" → "in A1, AMS 1, slot 4" or "on the A1's external holder".
|
// "<printer serial>/<unit>/<slot>" → "in AMS 1, slot 4" or "on the external holder".
|
||||||
function slotLabel(slot) {
|
function slotLabel(slot) {
|
||||||
const [serial, unit, idx] = String(slot || '').split('/');
|
const [, unit, idx] = String(slot || '').split('/');
|
||||||
if (unit === undefined) return '';
|
if (unit === undefined) return '';
|
||||||
const p = printerList.find((x) => x.serial === serial);
|
return unit === 'ext' ? 'on the external holder' : `in AMS ${Number(unit) + 1}, slot ${Number(idx) + 1}`;
|
||||||
const name = p ? printerName(p) : '';
|
|
||||||
if (unit === 'ext') return name ? `on the ${name}’s external holder` : 'on the external holder';
|
|
||||||
return `in ${name ? `${name}, ` : ''}AMS ${Number(unit) + 1}, slot ${Number(idx) + 1}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function spoolHtml(s) {
|
function spoolHtml(s) {
|
||||||
const size = s.total_g ? `${count(s.total_g)} g spool` : '';
|
const left = s.remaining_g == null ? 'amount unknown' : `${count(s.remaining_g)} g left`;
|
||||||
const left = s.remaining_g == null ? 'amount left unknown' : `${count(s.remaining_g)} g left`;
|
const of = s.total_g ? ` of ${count(s.total_g)} g` : '';
|
||||||
// What the printer read off the spool's RFID tag.
|
|
||||||
const tag = [
|
|
||||||
s.tag_uid && `RFID tag <code>${escapeHtml(s.tag_uid)}</code>`,
|
|
||||||
s.product_code && `product <code>${escapeHtml(s.product_code)}</code>`,
|
|
||||||
s.code && `filament code <code>${escapeHtml(s.code)}</code>`,
|
|
||||||
].filter(Boolean).join(' · ');
|
|
||||||
return `
|
return `
|
||||||
<div class="watch spool" data-spool-id="${s.id}">
|
<div class="watch spool" data-spool-id="${s.id}">
|
||||||
<div class="watch-head">
|
<div class="watch-head">
|
||||||
|
|
@ -2477,8 +2509,7 @@ function spoolHtml(s) {
|
||||||
<button type="button" class="btn btn-secondary btn-sm" data-spool="remove">Remove</button>
|
<button type="button" class="btn btn-secondary btn-sm" data-spool="remove">Remove</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="watch-meta">${escapeHtml([size, left].filter(Boolean).join(' · '))}</div>
|
<div class="watch-meta">${escapeHtml(left)}${escapeHtml(of)}</div>
|
||||||
${tag ? `<div class="watch-meta spool-tag">${tag}</div>` : ''}
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2590,247 +2621,6 @@ async function setSpoolRemaining(id, value) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- printers
|
|
||||||
// Each printer is a card: a drawing of its model, a nickname, and how to reach
|
|
||||||
// it. Access codes are sent to the server but never come back — a card only
|
|
||||||
// knows whether one is stored.
|
|
||||||
// A function, not a constant, so it can be used before this line has run.
|
|
||||||
function printerModels() {
|
|
||||||
return window.PRINTER_MODELS || { models: [], amsTypes: [], find: () => null, art: () => '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCount(id, n) {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (el) el.textContent = n;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The nickname when there is one, otherwise the model.
|
|
||||||
function printerName(p) {
|
|
||||||
return (p.nickname || '').trim() || p.model || (p.host ? `Printer at ${p.host}` : 'Printer');
|
|
||||||
}
|
|
||||||
|
|
||||||
function printerCardHtml(p) {
|
|
||||||
const key = p.id || 'new';
|
|
||||||
const models = ['<option value="">Choose a model…</option>']
|
|
||||||
.concat(printerModels().models.map((m) => `<option value="${escapeHtml(m.id)}"${m.id === p.model ? ' selected' : ''}>${escapeHtml(m.id)}</option>`))
|
|
||||||
.join('');
|
|
||||||
const amsTypes = printerModels().amsTypes
|
|
||||||
.map((a) => `<option value="${a.id}"${a.id === p.ams_type ? ' selected' : ''}>${escapeHtml(a.label)}</option>`)
|
|
||||||
.join('');
|
|
||||||
return `
|
|
||||||
<section class="ai-card printer-card" data-printer-id="${key}">
|
|
||||||
<div class="printer-art" aria-hidden="true">${printerModels().art(p.model)}</div>
|
|
||||||
<form class="printer-form" autocomplete="off">
|
|
||||||
<h3 class="printer-title">${escapeHtml(p.id ? printerName(p) : 'New printer')}</h3>
|
|
||||||
${p.id && !p.model ? '<p class="form-hint printer-ask">Which model is this? Pick it below and save.</p>' : ''}
|
|
||||||
<label>Nickname
|
|
||||||
<input type="text" name="nickname" maxlength="60" value="${escapeHtml(p.nickname || '')}"
|
|
||||||
placeholder="${escapeHtml(p.model || 'Leave blank to use the model')}">
|
|
||||||
</label>
|
|
||||||
<label>Model
|
|
||||||
<select name="model">${models}</select>
|
|
||||||
</label>
|
|
||||||
<label>AMS
|
|
||||||
<select name="ams_type">${p.ams_type ? '' : '<option value="" selected>Choose…</option>'}${amsTypes}</select>
|
|
||||||
</label>
|
|
||||||
<label>Address
|
|
||||||
<input type="text" name="host" maxlength="255" value="${escapeHtml(p.host || '')}" placeholder="192.168.1.50">
|
|
||||||
</label>
|
|
||||||
<label>Serial number
|
|
||||||
<input type="text" name="serial" maxlength="40" value="${escapeHtml(p.serial || '')}" placeholder="From the printer’s screen">
|
|
||||||
</label>
|
|
||||||
<label>Access code
|
|
||||||
<input type="password" name="access_code" maxlength="40" autocomplete="new-password"
|
|
||||||
placeholder="${p.codeSet ? 'Stored — type to replace' : 'From the printer’s network settings'}">
|
|
||||||
</label>
|
|
||||||
<div class="printer-actions">
|
|
||||||
<button type="submit" class="btn btn-primary btn-sm">${p.id ? 'Save' : 'Add printer'}</button>
|
|
||||||
${p.id ? '<button type="button" class="btn btn-secondary btn-sm" data-printer="test">Test & sync</button>' : ''}
|
|
||||||
<button type="button" class="btn btn-secondary btn-sm" data-printer="${p.id ? 'remove' : 'cancel'}">${p.id ? 'Remove' : 'Cancel'}</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-hint ai-test-result printer-result"></div>
|
|
||||||
</form>
|
|
||||||
</section>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderPrintersPanel() {
|
|
||||||
fileGrid.className = 'ai-panel';
|
|
||||||
resultCount.textContent = '';
|
|
||||||
try {
|
|
||||||
printerList = await getJson('/api/printers');
|
|
||||||
} catch (err) {
|
|
||||||
fileGrid.innerHTML = `<div class="empty-state"><h3>Couldn’t load your printers</h3><p>${escapeHtml(err.message)}</p></div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCount('printerCount', printerList.length);
|
|
||||||
fileGrid.innerHTML = `
|
|
||||||
<section class="ai-card">
|
|
||||||
<div class="ai-card-head">
|
|
||||||
<h3>Your printers</h3>
|
|
||||||
<button type="button" class="btn btn-primary btn-sm" id="addPrinterBtn">Add printer</button>
|
|
||||||
</div>
|
|
||||||
<p class="form-hint">Printhive listens to each printer on your network — it never sends a print or changes a
|
|
||||||
setting, so this works in cloud mode too. Every 10 minutes it reads what’s loaded and keeps the Filament page up
|
|
||||||
to date. The address, serial number and access code are on the printer’s screen under network settings.</p>
|
|
||||||
</section>
|
|
||||||
<div id="printerCards" class="printer-cards">
|
|
||||||
${printerList.length ? printerList.map(printerCardHtml).join('')
|
|
||||||
: '<p class="form-hint">No printers yet — add one to get started.</p>'}
|
|
||||||
</div>`;
|
|
||||||
document.getElementById('addPrinterBtn').addEventListener('click', addPrinterCard);
|
|
||||||
document.querySelectorAll('.printer-card').forEach(bindPrinterCard);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addPrinterCard() {
|
|
||||||
const cards = document.getElementById('printerCards');
|
|
||||||
if (cards.querySelector('[data-printer-id="new"]')) {
|
|
||||||
cards.querySelector('[data-printer-id="new"] input[name="nickname"]').focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!printerList.length) cards.innerHTML = '';
|
|
||||||
cards.insertAdjacentHTML('beforeend', printerCardHtml({}));
|
|
||||||
const card = cards.lastElementChild;
|
|
||||||
bindPrinterCard(card);
|
|
||||||
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
card.querySelector('select[name="model"]').focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindPrinterCard(card) {
|
|
||||||
const form = card.querySelector('form');
|
|
||||||
const id = card.dataset.printerId === 'new' ? null : Number(card.dataset.printerId);
|
|
||||||
const out = card.querySelector('.printer-result');
|
|
||||||
const say = (text, kind) => {
|
|
||||||
out.className = `form-hint ai-test-result printer-result${kind ? ` is-${kind}` : ''}`;
|
|
||||||
out.innerHTML = text;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Choosing a model redraws the picture and suggests the AMS that usually comes with it.
|
|
||||||
const modelSelect = form.elements.model;
|
|
||||||
const amsSelect = form.elements.ams_type;
|
|
||||||
let amsTouched = Boolean(id && amsSelect.value);
|
|
||||||
amsSelect.addEventListener('change', () => { amsTouched = true; });
|
|
||||||
const refreshTitle = () => {
|
|
||||||
const name = form.elements.nickname.value.trim() || modelSelect.value;
|
|
||||||
card.querySelector('.printer-title').textContent = name || 'New printer';
|
|
||||||
form.elements.nickname.placeholder = modelSelect.value || 'Leave blank to use the model';
|
|
||||||
};
|
|
||||||
modelSelect.addEventListener('change', () => {
|
|
||||||
card.querySelector('.printer-art').innerHTML = printerModels().art(modelSelect.value);
|
|
||||||
const m = printerModels().find(modelSelect.value);
|
|
||||||
if (m && !amsTouched) {
|
|
||||||
const blank = amsSelect.querySelector('option[value=""]');
|
|
||||||
if (blank) blank.remove();
|
|
||||||
amsSelect.value = m.ams;
|
|
||||||
}
|
|
||||||
refreshTitle();
|
|
||||||
});
|
|
||||||
form.elements.nickname.addEventListener('input', refreshTitle);
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const body = {
|
|
||||||
nickname: form.elements.nickname.value,
|
|
||||||
model: modelSelect.value,
|
|
||||||
ams_type: amsSelect.value,
|
|
||||||
host: form.elements.host.value,
|
|
||||||
serial: form.elements.serial.value,
|
|
||||||
};
|
|
||||||
// Left blank, the stored code is kept.
|
|
||||||
if (form.elements.access_code.value.trim()) body.access_code = form.elements.access_code.value.trim();
|
|
||||||
const submit = form.querySelector('button[type="submit"]');
|
|
||||||
submit.disabled = true;
|
|
||||||
try {
|
|
||||||
const saved = id
|
|
||||||
? await postJson(`/api/printers/${id}`, body, 'PUT')
|
|
||||||
: await postJson('/api/printers', body);
|
|
||||||
form.elements.access_code.value = '';
|
|
||||||
if (!id) {
|
|
||||||
await renderPrintersPanel();
|
|
||||||
const fresh = document.querySelector(`[data-printer-id="${saved.id}"] .printer-result`);
|
|
||||||
if (fresh) {
|
|
||||||
fresh.classList.add('is-ok');
|
|
||||||
fresh.textContent = '✓ Added. Use “Test & sync” to check the connection.';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
printerList = printerList.map((p) => (p.id === saved.id ? saved : p));
|
|
||||||
form.elements.access_code.placeholder = saved.codeSet ? 'Stored — type to replace' : 'From the printer’s network settings';
|
|
||||||
say('✓ Saved.', 'ok');
|
|
||||||
} catch (err) {
|
|
||||||
say(`✗ ${escapeHtml(err.message)}`, 'error');
|
|
||||||
} finally {
|
|
||||||
submit.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const cancel = card.querySelector('[data-printer="cancel"]');
|
|
||||||
if (cancel) {
|
|
||||||
cancel.addEventListener('click', () => {
|
|
||||||
card.remove();
|
|
||||||
if (!printerList.length) renderPrintersPanel();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const remove = card.querySelector('[data-printer="remove"]');
|
|
||||||
if (remove) {
|
|
||||||
remove.addEventListener('click', async () => {
|
|
||||||
if (!confirm(`Remove ${printerName(printerList.find((p) => p.id === id) || {})}? Its spools stay on the Filament page.`)) return;
|
|
||||||
try {
|
|
||||||
await getJson(`/api/printers/${id}`, { method: 'DELETE' });
|
|
||||||
await renderPrintersPanel();
|
|
||||||
} catch (err) {
|
|
||||||
say(`✗ ${escapeHtml(err.message)}`, 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const test = card.querySelector('[data-printer="test"]');
|
|
||||||
if (test) test.addEventListener('click', () => testPrinter(id, test, say));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listens to one printer for a few seconds, says what it reported, and updates
|
|
||||||
// the spools it has loaded. Only reads; the printer is never sent a command.
|
|
||||||
async function testPrinter(id, btn, say) {
|
|
||||||
say('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 {
|
|
||||||
const r = await postJson(`/api/printers/${id}/test`);
|
|
||||||
if (!r.connected) throw new Error(r.error || 'Couldn’t connect to the printer.');
|
|
||||||
const p = printerList.find((x) => x.id === id) || {};
|
|
||||||
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) {
|
|
||||||
// An AMS lite's percentage is only full-or-empty, so it isn't shown.
|
|
||||||
const show = p.ams_type === 'ams_lite' ? (t) => trayHtml({ ...t, remain: null }) : trayHtml;
|
|
||||||
lines.push(`AMS: ${loaded.length} of ${r.ams.length} slots loaded${loaded.length ? ` — ${loaded.map(show).join(', ')}` : ''}.`);
|
|
||||||
}
|
|
||||||
if (r.external && !r.external.empty) lines.push(`External spool: ${trayHtml({ ...r.external, remain: null })}.`);
|
|
||||||
if (r.sync) {
|
|
||||||
const s = r.sync;
|
|
||||||
const parts = [];
|
|
||||||
if (s.created) parts.push(`${plural(s.created, 'spool')} added`);
|
|
||||||
if (s.adopted) parts.push(`${plural(s.adopted, 'spool')} matched to ones you’d added`);
|
|
||||||
if (s.updated) parts.push(`${plural(s.updated, 'spool')} already known`);
|
|
||||||
if (s.unloaded) parts.push(`${plural(s.unloaded, 'spool')} marked as taken out`);
|
|
||||||
if (parts.length) lines.push(`Filament page: ${parts.join(', ')}.`);
|
|
||||||
const n = (s.untracked || []).length;
|
|
||||||
if (n) lines.push(`${plural(n, 'spool')} without an RFID tag ${n === 1 ? 'isn’t' : 'aren’t'} tracked automatically — add ${n === 1 ? 'it' : 'them'} by hand.`);
|
|
||||||
}
|
|
||||||
if (!r.reports) {
|
|
||||||
lines.push('But it sent nothing back — in cloud mode the printer may be refusing the request for a full report.');
|
|
||||||
}
|
|
||||||
say(lines.join(' '), r.reports ? 'ok' : 'error');
|
|
||||||
} catch (err) {
|
|
||||||
say(`✗ ${escapeHtml(err.message)}`, 'error');
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- watched collections
|
// ---------------------------------------------------------------- watched collections
|
||||||
let watchPoll = null;
|
let watchPoll = null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -106,10 +106,6 @@
|
||||||
<span>Filament</span>
|
<span>Filament</span>
|
||||||
<span class="count" id="spoolCount">0</span>
|
<span class="count" id="spoolCount">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="project-item" data-scope="printers">
|
|
||||||
<span>Printers</span>
|
|
||||||
<span class="count" id="printerCount">0</span>
|
|
||||||
</div>
|
|
||||||
<div class="project-item" data-scope="trash">
|
<div class="project-item" data-scope="trash">
|
||||||
<span>Trash</span>
|
<span>Trash</span>
|
||||||
<span class="count" id="trashCount">0</span>
|
<span class="count" id="trashCount">0</span>
|
||||||
|
|
@ -304,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 & 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">
|
||||||
|
|
@ -678,7 +701,6 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="viewer.js"></script>
|
<script type="module" src="viewer.js"></script>
|
||||||
<script src="printer-art.js"></script>
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
// Printer models, and simple line drawings of their shapes, drawn for
|
|
||||||
// Printhive. The drawings suggest a model's form — open frame, enclosed, large
|
|
||||||
// format — rather than depict any product exactly.
|
|
||||||
(function () {
|
|
||||||
const svg = (inner, width = 150) =>
|
|
||||||
`<svg viewBox="0 0 ${width} 120" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor"
|
|
||||||
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" role="img">${inner}</svg>`;
|
|
||||||
|
|
||||||
// An AMS lite: a stand with four spools around a hub.
|
|
||||||
const amsLite = (x) => `
|
|
||||||
<line x1="${x}" y1="104" x2="${x}" y2="52"/>
|
|
||||||
<line x1="${x - 10}" y1="104" x2="${x + 10}" y2="104"/>
|
|
||||||
<circle cx="${x - 9}" cy="40" r="7"/><circle cx="${x + 9}" cy="40" r="7"/>
|
|
||||||
<circle cx="${x - 9}" cy="58" r="7"/><circle cx="${x + 9}" cy="58" r="7"/>`;
|
|
||||||
|
|
||||||
// An AMS on top of an enclosed printer: a box showing four spools.
|
|
||||||
const amsTop = (x, width) => {
|
|
||||||
const step = width / 4;
|
|
||||||
let spools = '';
|
|
||||||
for (let i = 0; i < 4; i += 1) spools += `<circle cx="${x + step / 2 + i * step}" cy="23" r="5"/>`;
|
|
||||||
return `<rect x="${x}" y="14" width="${width}" height="18" rx="3"/>${spools}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toolhead = (x, y) => `<rect x="${x}" y="${y}" width="14" height="15" rx="2"/>
|
|
||||||
<line x1="${x + 7}" y1="${y + 15}" x2="${x + 7}" y2="${y + 20}"/>`;
|
|
||||||
|
|
||||||
const shapes = {
|
|
||||||
// A1: a bed on a base, an upright gantry, an AMS lite alongside.
|
|
||||||
open: svg(`
|
|
||||||
<rect x="12" y="94" width="92" height="10" rx="3"/>
|
|
||||||
<rect x="24" y="84" width="68" height="6" rx="2"/>
|
|
||||||
<line x1="20" y1="94" x2="20" y2="26"/><line x1="96" y1="94" x2="96" y2="26"/>
|
|
||||||
<line x1="16" y1="26" x2="100" y2="26"/><line x1="20" y1="56" x2="96" y2="56"/>
|
|
||||||
${toolhead(50, 49)}
|
|
||||||
${amsLite(126)}`),
|
|
||||||
|
|
||||||
// A1 mini: a single column holding the gantry out over the bed.
|
|
||||||
openMini: svg(`
|
|
||||||
<rect x="22" y="94" width="72" height="10" rx="3"/>
|
|
||||||
<rect x="36" y="84" width="50" height="6" rx="2"/>
|
|
||||||
<rect x="24" y="30" width="10" height="64" rx="2"/>
|
|
||||||
<line x1="34" y1="54" x2="90" y2="54"/>
|
|
||||||
${toolhead(58, 47)}
|
|
||||||
${amsLite(122)}`),
|
|
||||||
|
|
||||||
// A2L: the A1's form, stretched to a larger bed.
|
|
||||||
openLarge: svg(`
|
|
||||||
<rect x="6" y="94" width="104" height="10" rx="3"/>
|
|
||||||
<rect x="14" y="84" width="88" height="6" rx="2"/>
|
|
||||||
<line x1="10" y1="94" x2="10" y2="22"/><line x1="106" y1="94" x2="106" y2="22"/>
|
|
||||||
<line x1="6" y1="22" x2="110" y2="22"/><line x1="10" y1="54" x2="106" y2="54"/>
|
|
||||||
${toolhead(52, 47)}
|
|
||||||
${amsLite(132)}`),
|
|
||||||
|
|
||||||
// P1P: a bare cube frame.
|
|
||||||
frame: svg(`
|
|
||||||
<rect x="30" y="38" width="66" height="66" rx="3"/>
|
|
||||||
<path d="M30 38 L44 28 L110 28 L96 38 M110 28 L110 94 L96 104"/>
|
|
||||||
<line x1="30" y1="60" x2="96" y2="60"/>
|
|
||||||
${toolhead(56, 53)}
|
|
||||||
<rect x="40" y="90" width="46" height="5" rx="2"/>`, 140),
|
|
||||||
|
|
||||||
// P1S, P2S, X1C, X1E, X2D: an enclosed box with an AMS on top.
|
|
||||||
box: svg(`
|
|
||||||
${amsTop(30, 66)}
|
|
||||||
<rect x="28" y="36" width="70" height="70" rx="5"/>
|
|
||||||
<rect x="36" y="44" width="54" height="46" rx="3"/>
|
|
||||||
<line x1="36" y1="98" x2="90" y2="98"/>
|
|
||||||
${toolhead(56, 52)}`, 126),
|
|
||||||
|
|
||||||
// H2S, H2D, H2C: a wide enclosed box with an AMS on top.
|
|
||||||
large: svg(`
|
|
||||||
${amsTop(28, 94)}
|
|
||||||
<rect x="18" y="36" width="114" height="70" rx="5"/>
|
|
||||||
<rect x="26" y="44" width="98" height="46" rx="3"/>
|
|
||||||
<line x1="26" y1="98" x2="124" y2="98"/>
|
|
||||||
${toolhead(58, 52)}${toolhead(78, 52)}`),
|
|
||||||
|
|
||||||
// Anything else: a plain box with a nozzle.
|
|
||||||
generic: svg(`
|
|
||||||
<rect x="34" y="30" width="72" height="72" rx="8"/>
|
|
||||||
${toolhead(63, 50)}
|
|
||||||
<line x1="44" y1="92" x2="96" y2="92"/>`, 140),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ams is only a suggestion — a P1S might have an AMS, an AMS 2 Pro or an AMS HT.
|
|
||||||
const models = [
|
|
||||||
{ id: 'A1 mini', shape: 'openMini', ams: 'ams_lite' },
|
|
||||||
{ id: 'A1', shape: 'open', ams: 'ams_lite' },
|
|
||||||
{ id: 'A2L', shape: 'openLarge', ams: 'ams_lite' },
|
|
||||||
{ id: 'P1P', shape: 'frame', ams: 'ams' },
|
|
||||||
{ id: 'P1S', shape: 'box', ams: 'ams' },
|
|
||||||
{ id: 'P2S', shape: 'box', ams: 'ams_2_pro' },
|
|
||||||
{ id: 'X1C', shape: 'box', ams: 'ams' },
|
|
||||||
{ id: 'X1E', shape: 'box', ams: 'ams' },
|
|
||||||
{ id: 'X2D', shape: 'box', ams: 'ams_2_pro' },
|
|
||||||
{ id: 'H2S', shape: 'large', ams: 'ams_2_pro' },
|
|
||||||
{ id: 'H2D', shape: 'large', ams: 'ams_2_pro' },
|
|
||||||
{ id: 'H2C', shape: 'large', ams: 'ams_2_pro' },
|
|
||||||
{ id: 'Other', shape: 'generic', ams: 'none' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const amsTypes = [
|
|
||||||
{ id: 'none', label: 'No AMS (external spool)' },
|
|
||||||
{ id: 'ams_lite', label: 'AMS lite (full or empty only)' },
|
|
||||||
{ id: 'ams', label: 'AMS' },
|
|
||||||
{ id: 'ams_2_pro', label: 'AMS 2 Pro' },
|
|
||||||
{ id: 'ams_ht', label: 'AMS HT' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const find = (id) => models.find((m) => m.id === id) || null;
|
|
||||||
|
|
||||||
window.PRINTER_MODELS = {
|
|
||||||
models,
|
|
||||||
amsTypes,
|
|
||||||
find,
|
|
||||||
art: (id) => shapes[(find(id) || {}).shape] || shapes.generic,
|
|
||||||
};
|
|
||||||
}());
|
|
||||||
|
|
@ -487,22 +487,3 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
|
||||||
.watch-meta { font-family: var(--mono); font-size: 12px; color: var(--text-dim); }
|
.watch-meta { font-family: var(--mono); font-size: 12px; color: var(--text-dim); }
|
||||||
.watch-recent { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--text-muted); max-height: 180px; overflow-y: auto; }
|
.watch-recent { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--text-muted); max-height: 180px; overflow-y: auto; }
|
||||||
#watchStatus.is-error { color: var(--danger); }
|
#watchStatus.is-error { color: var(--danger); }
|
||||||
|
|
||||||
/* ---- Printers ---- */
|
|
||||||
.printer-cards { display: flex; flex-direction: column; gap: 18px; }
|
|
||||||
.printer-card { flex-direction: row; align-items: flex-start; gap: 20px; }
|
|
||||||
.printer-art { flex: 0 0 150px; color: var(--text-muted); padding-top: 28px; }
|
|
||||||
.printer-art svg { width: 100%; height: auto; display: block; }
|
|
||||||
.printer-form { flex: 1; min-width: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 10px 14px; }
|
|
||||||
.printer-form label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--text-muted); }
|
|
||||||
.printer-form input, .printer-form select { width: 100%; }
|
|
||||||
.printer-title, .printer-actions, .printer-result { grid-column: 1 / -1; }
|
|
||||||
.printer-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
||||||
.printer-actions .btn-sm { align-self: auto; }
|
|
||||||
.printer-result:empty { display: none; }
|
|
||||||
.spool-tag code { color: var(--text-muted); }
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.printer-card { flex-direction: column; }
|
|
||||||
.printer-art { flex-basis: auto; width: 140px; padding-top: 0; }
|
|
||||||
}
|
|
||||||
.printer-ask { grid-column: 1 / -1; color: var(--accent); }
|
|
||||||
|
|
|
||||||
80
server.js
80
server.js
|
|
@ -31,36 +31,22 @@ 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 printer = require('./lib/printer');
|
||||||
const printers = require('./lib/printers');
|
|
||||||
const spoolSync = require('./lib/spoolSync');
|
const spoolSync = require('./lib/spoolSync');
|
||||||
|
|
||||||
// Reads one printer and brings the Filament page up to date with what it has
|
// Reads the configured printer and brings the Filament page up to date with
|
||||||
// loaded. Read-only toward the printer.
|
// what it has loaded. Read-only toward the printer.
|
||||||
async function syncPrinter(p, { debug = false } = {}) {
|
async function syncConfiguredPrinter({ debug = false } = {}) {
|
||||||
const missing = [!p.host && 'address', !p.serial && 'serial number', !p.access_code && 'access code']
|
const host = (await resolveSetting('printer_host')).value;
|
||||||
.filter(Boolean);
|
const serial = (await resolveSetting('printer_serial')).value;
|
||||||
if (missing.length) return { connected: false, error: `Fill in the printer’s ${missing.join(', ')} first.` };
|
const accessCode = (await resolveSetting('printer_access_code')).value;
|
||||||
const result = await printer.probe({ host: p.host, serial: p.serial, accessCode: p.access_code, debug });
|
if (!host || !serial || !accessCode) return null;
|
||||||
|
const result = await printer.probe({ host: host.trim(), serial: serial.trim(), accessCode: accessCode.trim(), debug });
|
||||||
if (result.connected) {
|
if (result.connected) {
|
||||||
const trays = [...(result.ams || []), ...(result.external ? [result.external] : [])];
|
const trays = [...(result.ams || []), ...(result.external ? [result.external] : [])];
|
||||||
if (trays.length) {
|
if (trays.length) result.sync = await spoolSync.syncPrinter(serial.trim(), trays, filamentPalette.filaments);
|
||||||
result.sync = await spoolSync.syncPrinter(p.serial, trays, filamentPalette.filaments,
|
|
||||||
{ measures: printers.measures(p.ams_type) });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every printer, one after another; one that's off or unreachable is skipped.
|
|
||||||
async function syncAllPrinters() {
|
|
||||||
for (const p of await printers.listWithCodes()) {
|
|
||||||
try {
|
|
||||||
await syncPrinter(p);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`Printer ${p.nickname || p.model || p.id} skipped:`, err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const embeddings = require('./lib/embeddings');
|
const embeddings = require('./lib/embeddings');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
@ -153,6 +139,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_'));
|
||||||
|
|
@ -355,38 +344,17 @@ 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.
|
||||||
// ----------------------------------------------------------------- printers
|
// Tries the printer connection with the saved settings and says what came back.
|
||||||
// Access codes go in but never come back out: responses carry `codeSet`.
|
// Read-only, and the access code never leaves the server.
|
||||||
app.get('/api/printers', async (req, res, next) => {
|
app.post('/api/printer/test', async (req, res, next) => {
|
||||||
try { res.json(await printers.list()); } catch (e) { next(e); }
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/api/printers', async (req, res, next) => {
|
|
||||||
try { res.status(201).json(await printers.create(req.body || {})); } catch (e) { next(e); }
|
|
||||||
});
|
|
||||||
|
|
||||||
app.put('/api/printers/:id', async (req, res, next) => {
|
|
||||||
try {
|
try {
|
||||||
const saved = await printers.update(Number(req.params.id), req.body || {});
|
const host = (await resolveSetting('printer_host')).value;
|
||||||
if (!saved) return res.status(404).json({ error: 'No such printer' });
|
const serial = (await resolveSetting('printer_serial')).value;
|
||||||
res.json(saved);
|
const accessCode = (await resolveSetting('printer_access_code')).value;
|
||||||
} catch (e) { next(e); }
|
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.` });
|
||||||
|
// ?debug=1 adds one tray's raw fields — filament data, no credentials.
|
||||||
app.delete('/api/printers/:id', async (req, res, next) => {
|
res.json(await syncConfiguredPrinter({ debug: req.query.debug === '1' }));
|
||||||
try {
|
|
||||||
if (!(await printers.remove(Number(req.params.id)))) return res.status(404).json({ error: 'No such printer' });
|
|
||||||
res.json({ message: 'Printer removed' });
|
|
||||||
} catch (e) { next(e); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Connects to one printer, says what it reported, and updates the spools it
|
|
||||||
// has loaded. ?debug=1 adds one tray's raw fields — filament data, no credentials.
|
|
||||||
app.post('/api/printers/:id/test', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const p = await printers.get(Number(req.params.id), { withCode: true });
|
|
||||||
if (!p) return res.status(404).json({ error: 'No such printer' });
|
|
||||||
res.json(await syncPrinter(p, { debug: req.query.debug === '1' }));
|
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1629,8 +1597,8 @@ db.init()
|
||||||
|
|
||||||
// Keep spools in step with the printer: shortly after start, then every
|
// Keep spools in step with the printer: shortly after start, then every
|
||||||
// ten minutes. A printer that's off or unreachable is simply skipped.
|
// ten minutes. A printer that's off or unreachable is simply skipped.
|
||||||
const syncQuietly = () => syncAllPrinters()
|
const syncQuietly = () => syncConfiguredPrinter()
|
||||||
.catch((err) => console.warn('Printer sync failed:', err.message));
|
.catch((err) => console.warn('Printer sync skipped:', err.message));
|
||||||
setTimeout(syncQuietly, 30 * 1000).unref();
|
setTimeout(syncQuietly, 30 * 1000).unref();
|
||||||
setInterval(syncQuietly, 10 * 60 * 1000).unref();
|
setInterval(syncQuietly, 10 * 60 * 1000).unref();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const { publicPrinter, measures } = require('../lib/printers');
|
|
||||||
|
|
||||||
test('a printer sent to the browser never carries its access code', () => {
|
|
||||||
const p = publicPrinter({ id: 1, nickname: null, model: 'A1', serial: 'S1', access_code: 'secret' });
|
|
||||||
assert.equal('access_code' in p, false);
|
|
||||||
assert.equal(p.codeSet, true);
|
|
||||||
assert.equal(publicPrinter({ id: 2, access_code: null }).codeSet, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('only AMS types that weigh filament count as measuring', () => {
|
|
||||||
assert.equal(measures('ams_lite'), false);
|
|
||||||
assert.equal(measures('none'), false);
|
|
||||||
assert.equal(measures(null), false);
|
|
||||||
assert.equal(measures('ams'), true);
|
|
||||||
assert.equal(measures('ams_2_pro'), true);
|
|
||||||
assert.equal(measures('ams_ht'), true);
|
|
||||||
});
|
|
||||||
|
|
@ -28,7 +28,7 @@ test('a spool the shelf already knows is moved, not added again', () => {
|
||||||
const existing = [{ id: 7, material: 'PLA', colour_hex: '#000000', rfid_uuid: 'U-BLACK',
|
const existing = [{ id: 7, material: 'PLA', colour_hex: '#000000', rfid_uuid: 'U-BLACK',
|
||||||
printer_serial: SERIAL, ams_slot: 'PRINTER1/0/3' }];
|
printer_serial: SERIAL, ams_slot: 'PRINTER1/0/3' }];
|
||||||
const { actions } = planSync(existing, [tray(1, '#000000', 'U-BLACK')], SERIAL, palette);
|
const { actions } = planSync(existing, [tray(1, '#000000', 'U-BLACK')], SERIAL, palette);
|
||||||
assert.deepEqual(actions, [{ op: 'update', id: 7, slot: 'PRINTER1/0/1', printerSerial: SERIAL, remaining: null }]);
|
assert.deepEqual(actions, [{ op: 'update', id: 7, slot: 'PRINTER1/0/1', printerSerial: SERIAL }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a spool added by hand is adopted by the printer rather than duplicated', () => {
|
test('a spool added by hand is adopted by the printer rather than duplicated', () => {
|
||||||
|
|
@ -82,17 +82,3 @@ test('palette matching names a colour within its product line, and doesn’t gue
|
||||||
assert.equal(paletteMatch(palette, 'PLA', 'Matte', '#FFFFFF').name, 'Ivory White');
|
assert.equal(paletteMatch(palette, 'PLA', 'Matte', '#FFFFFF').name, 'Ivory White');
|
||||||
assert.equal(paletteMatch(palette, 'PLA', 'Basic', '#123456'), null);
|
assert.equal(paletteMatch(palette, 'PLA', 'Basic', '#123456'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an AMS that measures fills in grams left; an AMS lite never does', () => {
|
|
||||||
const trays = [tray(0, '#C12E1F', 'U-RED', { remain: 42 })];
|
|
||||||
const lite = planSync([], trays, SERIAL, palette, { measures: false });
|
|
||||||
assert.equal(lite.actions[0].spool.remaining_g, null);
|
|
||||||
const pro = planSync([], trays, SERIAL, palette, { measures: true });
|
|
||||||
assert.equal(pro.actions[0].spool.remaining_g, 420);
|
|
||||||
|
|
||||||
const existing = [{ id: 7, material: 'PLA', colour_hex: '#C12E1F', rfid_uuid: 'U-RED', printer_serial: SERIAL, ams_slot: 'PRINTER1/0/0' }];
|
|
||||||
assert.equal(planSync(existing, trays, SERIAL, palette, { measures: true }).actions[0].remaining, 420);
|
|
||||||
// No reading at all leaves the stored amount alone.
|
|
||||||
const unknown = [tray(0, '#C12E1F', 'U-RED', { remain: null })];
|
|
||||||
assert.equal(planSync(existing, unknown, SERIAL, palette, { measures: true }).actions[0].remaining, null);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue