diff --git a/README.md b/README.md index db21d37..735d206 100644 --- a/README.md +++ b/README.md @@ -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. - **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. -- **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. Printhive stays connected to each printer and its card shows what it's doing live: state, the file printing, progress, time left and finish time, layer, nozzle, bed and chamber temperatures, the speed setting, and any error or HMS codes. The drawing colours in the spools loaded in the AMS, with every slot listed underneath. "Test & sync" asks a printer for a full report straight away. The connection only listens — Printhive never sends a printer a command, other than asking for a full report — and access codes stay on your server. Spools a printer reads from their RFID tags are added to the Filament page by themselves, within seconds of going in: 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. +- **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. Printhive stays connected to each printer and its card shows what it's doing live: state, the file printing, progress, time left and finish time, layer, nozzle, bed and chamber temperatures, the speed setting, and any error or HMS codes. The drawing colours in the spools loaded in the AMS, with every slot listed underneath. "Test & sync" asks a printer for a full report straight away. "Camera" shows the chamber camera on a P1 or A1 series printer — about a picture a second, connected only while you're watching and never recorded. The connection only listens — Printhive never sends a printer a command, other than asking for a full report — and access codes stay on your server. Spools a printer reads from their RFID tags are added to the Filament page by themselves, within seconds of going in: 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. - **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. diff --git a/lib/printerCamera.js b/lib/printerCamera.js index 9337960..7b7018b 100644 --- a/lib/printerCamera.js +++ b/lib/printerCamera.js @@ -96,6 +96,42 @@ function watch({ host, accessCode, onFrame, onError = () => {}, connectTimeoutMs }; } +// One connection per printer, however many people are watching, closed as soon +// as the last of them looks away. Frames aren't stored beyond the latest one, +// which new viewers get straight away instead of waiting for the next. +const feeds = new Map(); + +function subscribe(id, { host, accessCode }, onFrame, onError = () => {}) { + let feed = feeds.get(id); + if (!feed) { + feed = { viewers: new Set(), latest: null, stop: () => {} }; + feeds.set(id, feed); + feed.stop = watch({ + host, + accessCode, + onFrame: (frame) => { + feed.latest = frame; + for (const v of feed.viewers) v.onFrame(frame); + }, + onError: (error) => { + feeds.delete(id); + for (const v of feed.viewers) v.onError(error); + feed.viewers.clear(); + }, + }); + } + const viewer = { onFrame, onError }; + feed.viewers.add(viewer); + if (feed.latest) onFrame(feed.latest); + return () => { + feed.viewers.delete(viewer); + if (!feed.viewers.size) { + feed.stop(); + feeds.delete(id); + } + }; +} + // Connects, waits for one frame, and says what came back. Used to check a // printer's camera without opening a viewer. function testCamera({ host, accessCode, waitMs = 10000 }) { @@ -120,4 +156,4 @@ function testCamera({ host, accessCode, waitMs = 10000 }) { }); } -module.exports = { watch, testCamera, jpegSize, hello, PORT }; +module.exports = { watch, subscribe, testCamera, jpegSize, hello, PORT }; diff --git a/public/app.js b/public/app.js index 2fcb577..cb1ccab 100644 --- a/public/app.js +++ b/public/app.js @@ -2785,8 +2785,13 @@ function printerCardHtml(p) { + ${p.id ? `` : ''}
+ ${p.id ? '' : ''} ${p.id ? '' : ''}
@@ -3053,6 +3058,31 @@ function bindPrinterCard(card) { }); } + // The camera runs only while it's open: closing it drops the connection. + const camera = card.querySelector('[data-printer="camera"]'); + if (camera) { + const box = card.querySelector('.printer-camera'); + const img = box.querySelector('img'); + const note = box.querySelector('.camera-note'); + img.addEventListener('load', () => { note.hidden = true; }); + img.addEventListener('error', () => { + note.hidden = false; + note.textContent = 'No picture from this printer’s camera. Only P1 and A1 series printers can do this.'; + }); + camera.addEventListener('click', () => { + const showing = box.hidden; + box.hidden = !showing; + camera.textContent = showing ? 'Hide camera' : 'Camera'; + if (showing) { + note.hidden = false; + note.textContent = 'Connecting to the camera…'; + img.src = `/api/printers/${id}/camera?t=${Date.now()}`; + } else { + img.removeAttribute('src'); // stops the stream at the printer + } + }); + } + const test = card.querySelector('[data-printer="test"]'); if (test) test.addEventListener('click', () => testPrinter(id, test, say, card)); } diff --git a/public/styles.css b/public/styles.css index ab0f111..fe81a35 100644 --- a/public/styles.css +++ b/public/styles.css @@ -533,3 +533,6 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut .feed-flow { animation: feed-flow 0.8s linear infinite; } @keyframes feed-flow { to { stroke-dashoffset: -8; } } @media (prefers-reduced-motion: reduce) { .feed-flow { animation: none; } } +.printer-camera { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 6px; } +.printer-camera img { width: 100%; max-width: 520px; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--surface-2); display: block; } +.printer-camera img:not([src]) { display: none; } diff --git a/server.js b/server.js index 951aaf0..1ee5d97 100644 --- a/server.js +++ b/server.js @@ -377,6 +377,35 @@ app.delete('/api/printers/:id', async (req, res, next) => { } catch (e) { next(e); } }); +// The chamber camera as a stream of pictures, which an can show directly. +// The connection to the printer is made here, so the access code stays put, and +// it's dropped as soon as the last viewer looks away. Nothing is recorded. +app.get('/api/printers/:id/camera', 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' }); + if (!p.host || !p.access_code) return res.status(400).json({ error: 'Fill in the printer’s address and access code first.' }); + + const boundary = 'printhiveframe'; + res.writeHead(200, { + 'Content-Type': `multipart/x-mixed-replace; boundary=${boundary}`, + 'Cache-Control': 'no-store, no-cache, must-revalidate', + Connection: 'close', + Pragma: 'no-cache', + }); + + const unsubscribe = printerCamera.subscribe(p.id, { host: p.host, accessCode: p.access_code }, + (frame) => { + if (res.writableEnded) return; + res.write(`--${boundary}\r\nContent-Type: image/jpeg\r\nContent-Length: ${frame.length}\r\n\r\n`); + res.write(frame); + res.write('\r\n'); + }, + () => { if (!res.writableEnded) res.end(); }); + req.on('close', () => { unsubscribe(); if (!res.writableEnded) res.end(); }); + } catch (e) { next(e); } +}); + // Tries the printer's chamber camera and says whether a picture arrived. // P1 and A1 series only, and the printer needs "LAN Mode Liveview" switched on. app.post('/api/printers/:id/camera/test', async (req, res, next) => {