From 59a4e11cbb06c6a0a27287f4d19f5887abf5c493 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:15:50 +0000 Subject: [PATCH 01/70] feat: add FlashForge local web app (camera, status, controls, upload) --- webapp/.env.example | 16 + webapp/.gitignore | 3 + webapp/README.md | 115 +++ webapp/frontend/public/app.js | 395 ++++++++ webapp/frontend/public/index.html | 150 +++ webapp/frontend/public/style.css | 345 +++++++ webapp/package-lock.json | 1431 +++++++++++++++++++++++++++++ webapp/package.json | 19 + webapp/server.js | 257 ++++++ 9 files changed, 2731 insertions(+) create mode 100644 webapp/.env.example create mode 100644 webapp/.gitignore create mode 100644 webapp/README.md create mode 100644 webapp/frontend/public/app.js create mode 100644 webapp/frontend/public/index.html create mode 100644 webapp/frontend/public/style.css create mode 100644 webapp/package-lock.json create mode 100644 webapp/package.json create mode 100644 webapp/server.js diff --git a/webapp/.env.example b/webapp/.env.example new file mode 100644 index 0000000..0e5be3b --- /dev/null +++ b/webapp/.env.example @@ -0,0 +1,16 @@ +# FlashForge Web App — Configurazione +# Copia questo file in .env e compila i valori + +# Indirizzo IP della stampante in rete locale +PRINTER_IP=192.168.1.100 + +# Numero di serie della stampante (es. SN-XXXXXX) +# Reperibile sull'etichetta della stampante o nell'app FlashForge +SERIAL_NUMBER=your_serial_number_here + +# CheckCode per autenticazione API locale +# Reperibile nell'app FlashForge: Dispositivo → Impostazioni → Connessione LAN +CHECK_CODE=your_check_code_here + +# Porta su cui gira questa web app +PORT=3000 diff --git a/webapp/.gitignore b/webapp/.gitignore new file mode 100644 index 0000000..bd6a1b7 --- /dev/null +++ b/webapp/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +uploads/ diff --git a/webapp/README.md b/webapp/README.md new file mode 100644 index 0000000..22066bd --- /dev/null +++ b/webapp/README.md @@ -0,0 +1,115 @@ +# FlashForge Web App + +Web app locale per controllare la tua stampante FlashForge AD5M (o AD5X / 5M Pro) direttamente dal browser, senza cloud. + +## Funzionalità + +| Funzione | Dettaglio | +|---|---| +| 🎥 Camera live | Stream MJPEG proxato, attivazione/disattivazione in-app | +| 🌡 Temperature | Ugello, piatto e camera — valori correnti e target | +| 📊 Info stampa | Layer corrente / totale, progresso, tempo rimanente, nome file | +| ⏸ ▶ ⏹ Controlli | Pausa, Riprendi, Stop della stampa corrente | +| 📂 File in memoria | Lista file sulla stampante, anteprima thumbnail, avvio stampa | +| ⬆ Upload GCode | Carica `.gcode` / `.g` / `.gx` / `.3mf` con opzione "Stampa subito" | + +## Requisiti + +- [Node.js](https://nodejs.org/) 18 o superiore +- Stampante FlashForge sulla stessa rete locale + +## Installazione + +```bash +cd webapp +npm install +cp .env.example .env +``` + +Apri `.env` e inserisci: + +```env +PRINTER_IP=192.168.1.XXX # IP della stampante in LAN +SERIAL_NUMBER=SN-XXXXXXXX # Numero di serie (etichetta sulla stampante) +CHECK_CODE=XXXXXXXX # CheckCode LAN (vedi sotto) +PORT=3000 # Porta della web app (opzionale) +``` + +### Come trovare il CheckCode + +1. Apri l'app **FlashForge** sul tuo telefono +2. Seleziona la stampante → **Impostazioni** → **Connessione LAN** +3. Il codice a 8 cifre mostrato è il `checkCode` + +In alternativa puoi trovarlo ispezionando il traffico di rete con un proxy (es. Charles, mitmproxy) mentre l'app si connette alla stampante. + +## Avvio + +```bash +npm start +``` + +Apri il browser su **http://localhost:3000** + +Per lo sviluppo con auto-reload: + +```bash +npm run dev +``` + +## Struttura del progetto + +``` +webapp/ +├── server.js # Backend Express (proxy API FlashForge) +├── package.json +├── .env.example # Template configurazione +├── .gitignore +└── frontend/ + └── public/ + ├── index.html # UI principale + ├── style.css # Stili dark mode + └── app.js # Logica frontend (polling, upload, ecc.) +``` + +## API Backend esposte + +| Endpoint | Metodo | Descrizione | +|---|---|---| +| `/api/status` | GET | Stato completo della stampante | +| `/api/control` | POST | Pausa / Riprendi / Stop | +| `/api/files` | GET | Lista file in memoria stampante | +| `/api/thumb?fileName=` | GET | Thumbnail base64 di un file | +| `/api/print` | POST | Avvia stampa da file in memoria | +| `/api/upload` | POST | Upload GCode dal browser | +| `/api/camera/stream` | GET | Proxy stream MJPEG camera | +| `/api/camera` | POST | Attiva / disattiva camera | +| `/api/config` | GET | Verifica configurazione | + +## Docker (opzionale) + +```dockerfile +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev +COPY . . +EXPOSE 3000 +CMD ["node", "server.js"] +``` + +```bash +docker build -t flashforge-webapp . +docker run -d -p 3000:3000 --env-file .env flashforge-webapp +``` + +## Note + +- La stampante deve essere **sulla stessa rete locale** del server che esegue questa app. +- Il **CheckCode** autentica ogni richiesta HTTP alla porta `8898` della stampante; senza di esso le API restituiscono errore. +- Lo stream camera usa la porta `8080` della stampante (MJPEG over HTTP, senza autenticazione). +- Se la camera risulta spenta, usa il pulsante **"Attiva camera"** che invia prima il comando `streamCtrl_cmd` alla stampante. + +--- + +*Progetto non ufficiale, non affiliato con FlashForge.* diff --git a/webapp/frontend/public/app.js b/webapp/frontend/public/app.js new file mode 100644 index 0000000..f8a77e7 --- /dev/null +++ b/webapp/frontend/public/app.js @@ -0,0 +1,395 @@ +'use strict'; + +/* ── State ───────────────────────────────────────────────────────────────── */ +let currentJobID = null; +let currentStatus = null; +let pollingTimer = null; +let cameraActive = false; + +/* ── DOM refs ────────────────────────────────────────────────────────────── */ +const badge = document.getElementById('status-badge'); +const cameraImg = document.getElementById('camera-img'); +const cameraPlaceholder = document.getElementById('camera-placeholder'); +const btnCameraOn = document.getElementById('btn-camera-on'); +const btnCameraOff = document.getElementById('btn-camera-off'); +const sFname = document.getElementById('s-filename'); +const sProgress = document.getElementById('s-progress'); +const sLayer = document.getElementById('s-layer'); +const sTime = document.getElementById('s-time'); +const progressBar = document.getElementById('progress-bar'); +const tNozzle = document.getElementById('t-nozzle'); +const tNozzleTarget = document.getElementById('t-nozzle-target'); +const tBed = document.getElementById('t-bed'); +const tBedTarget = document.getElementById('t-bed-target'); +const tChamber = document.getElementById('t-chamber'); +const tChamberTarget = document.getElementById('t-chamber-target'); +const btnPause = document.getElementById('btn-pause'); +const btnResume = document.getElementById('btn-resume'); +const btnStop = document.getElementById('btn-stop'); +const ctrlMsg = document.getElementById('ctrl-message'); +const lastUpdate = document.getElementById('last-update'); + +const btnRefreshFiles = document.getElementById('btn-refresh-files'); +const fileList = document.getElementById('file-list'); +const printModal = document.getElementById('print-modal'); +const modalFilename = document.getElementById('modal-filename'); +const modalLeveling = document.getElementById('modal-leveling'); +const modalConfirm = document.getElementById('modal-confirm'); +const modalCancel = document.getElementById('modal-cancel'); + +const uploadForm = document.getElementById('upload-form'); +const fileInput = document.getElementById('file-input'); +const dropText = document.getElementById('drop-text'); +const dropArea = document.getElementById('drop-area'); +const printNowChk = document.getElementById('print-now'); +const levelingUpload = document.getElementById('leveling-upload'); +const btnUpload = document.getElementById('btn-upload'); +const uploadProgressWrap = document.getElementById('upload-progress'); +const uploadProgressBar = document.getElementById('upload-progress-bar'); +const uploadProgressText = document.getElementById('upload-progress-text'); +const uploadMessage = document.getElementById('upload-message'); + +/* ── Utilities ───────────────────────────────────────────────────────────── */ +function fmt(v, unit = '°C') { + return v !== undefined && v !== null ? `${Math.round(v)}${unit}` : '—'; +} +function fmtTime(seconds) { + if (!seconds || seconds < 0) return '—'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; +} +function showCtrlMsg(msg, ok = true) { + ctrlMsg.textContent = msg; + ctrlMsg.style.color = ok ? 'var(--success)' : 'var(--danger)'; + setTimeout(() => { ctrlMsg.textContent = ''; }, 4000); +} +function showUploadMsg(msg, ok = true) { + uploadMessage.textContent = msg; + uploadMessage.style.color = ok ? 'var(--success)' : 'var(--danger)'; +} + +/* ── Status polling ──────────────────────────────────────────────────────── */ +async function fetchStatus() { + try { + const res = await fetch('/api/status'); + const json = await res.json(); + if (json.detail) updateUI(json.detail); + lastUpdate.textContent = `Ultimo aggiornamento: ${new Date().toLocaleTimeString('it-IT')}`; + } catch (e) { + badge.textContent = 'Errore connessione'; + badge.className = 'badge badge--error'; + } +} + +function updateUI(d) { + currentStatus = d.status || 'IDLE'; + + // Badge + const statusMap = { + PRINTING: ['badge--printing', 'STAMPA'], + PAUSED: ['badge--paused', 'IN PAUSA'], + IDLE: ['badge--idle', 'INATTIVA'], + ERROR: ['badge--error', 'ERRORE'], + HOMING: ['badge--printing', 'HOMING'], + }; + const [cls, label] = statusMap[currentStatus] || ['badge--idle', currentStatus]; + badge.className = `badge ${cls}`; + badge.textContent = label; + + // Job info + currentJobID = d.jobInfo ? (d.jobInfo[0] && d.jobInfo[0][1]) : null; + sFname.textContent = d.printFileName || '—'; + sFname.title = d.printFileName || ''; + + const pct = d.printProgress != null ? Math.round(d.printProgress * 100) : null; + sProgress.textContent = pct !== null ? `${pct}%` : '—'; + progressBar.style.width = pct !== null ? `${pct}%` : '0%'; + + const layer = d.printLayer ?? null; + const maxLayer = d.targetPrintLayer ?? null; + sLayer.textContent = (layer !== null && maxLayer !== null) + ? `${layer} / ${maxLayer}` + : (layer !== null ? String(layer) : '—'); + + sTime.textContent = fmtTime(d.estimatedTime); + + // Temperatures + tNozzle.textContent = fmt(d.rightTemp); + tNozzleTarget.textContent = d.rightTargetTemp ? `→ ${fmt(d.rightTargetTemp)}` : ''; + tBed.textContent = fmt(d.platTemp); + tBedTarget.textContent = d.platTargetTemp ? `→ ${fmt(d.platTargetTemp)}` : ''; + tChamber.textContent = fmt(d.chamberTemp); + tChamberTarget.textContent = d.chamberTargetTemp ? `→ ${fmt(d.chamberTargetTemp)}` : ''; + + // Controls enable/disable + const isPrinting = currentStatus === 'PRINTING'; + const isPaused = currentStatus === 'PAUSED'; + btnPause.disabled = !isPrinting; + btnResume.disabled = !isPaused; + btnStop.disabled = !(isPrinting || isPaused); + + // Camera: if the printer reports a stream URL, auto-enable + if (d.cameraStreamUrl && !cameraActive) { + enableCamera(); + } +} + +/* ── Camera ──────────────────────────────────────────────────────────────── */ +function enableCamera() { + cameraImg.src = `/api/camera/stream?t=${Date.now()}`; + cameraImg.classList.add('active'); + cameraPlaceholder.classList.add('hidden'); + cameraActive = true; +} +function disableCamera() { + cameraImg.src = ''; + cameraImg.classList.remove('active'); + cameraPlaceholder.classList.remove('hidden'); + cameraActive = false; +} + +btnCameraOn.addEventListener('click', async () => { + try { + await fetch('/api/camera', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'open' }) }); + } catch (_) { /* ignore – try to show stream anyway */ } + enableCamera(); +}); + +btnCameraOff.addEventListener('click', async () => { + disableCamera(); + try { + await fetch('/api/camera', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'close' }) }); + } catch (_) { /* ignore */ } +}); + +cameraImg.addEventListener('error', () => { + disableCamera(); +}); + +/* ── Print controls ──────────────────────────────────────────────────────── */ +async function sendControl(action) { + if (!currentJobID) { showCtrlMsg('Nessun job attivo.', false); return; } + try { + const res = await fetch('/api/control', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action, jobID: currentJobID }), + }); + const json = await res.json(); + if (json.code === 0) { + showCtrlMsg(`Comando "${action}" inviato.`); + await fetchStatus(); + } else { + showCtrlMsg(`Errore: ${json.message || json.code}`, false); + } + } catch (e) { + showCtrlMsg(`Errore di rete: ${e.message}`, false); + } +} + +btnPause.addEventListener('click', () => sendControl('pause')); +btnResume.addEventListener('click', () => sendControl('resume')); +btnStop.addEventListener('click', async () => { + if (!confirm('Sei sicuro di voler interrompere la stampa?')) return; + await sendControl('stop'); +}); + +/* ── File list ───────────────────────────────────────────────────────────── */ +let selectedFileName = null; + +btnRefreshFiles.addEventListener('click', loadFiles); + +async function loadFiles() { + fileList.innerHTML = '

Caricamento…

'; + try { + const res = await fetch('/api/files'); + const json = await res.json(); + const files = json.gcodeList || []; + if (!files.length) { + fileList.innerHTML = '

Nessun file trovato nella stampante.

'; + return; + } + fileList.innerHTML = ''; + files.forEach(renderFileItem); + } catch (e) { + fileList.innerHTML = `

Errore: ${e.message}

`; + } +} + +async function renderFileItem(fileName) { + const item = document.createElement('div'); + item.className = 'file-item'; + + // Thumb + const thumbWrap = document.createElement('div'); + thumbWrap.className = 'file-thumb-placeholder'; + thumbWrap.textContent = '📄'; + item.appendChild(thumbWrap); + + const nameEl = document.createElement('span'); + nameEl.className = 'file-name'; + nameEl.textContent = fileName; + item.appendChild(nameEl); + + item.addEventListener('click', () => openPrintModal(fileName)); + fileList.appendChild(item); + + // Async thumb load + try { + const res = await fetch(`/api/thumb?fileName=${encodeURIComponent(fileName)}`); + const json = await res.json(); + if (json.imageData) { + const img = document.createElement('img'); + img.className = 'file-thumb'; + img.src = `data:image/png;base64,${json.imageData}`; + img.alt = fileName; + thumbWrap.replaceWith(img); + } + } catch (_) { /* keep placeholder */ } +} + +function openPrintModal(fileName) { + selectedFileName = fileName; + modalFilename.textContent = fileName; + modalLeveling.checked = false; + printModal.classList.remove('hidden'); +} + +modalCancel.addEventListener('click', () => { + printModal.classList.add('hidden'); + selectedFileName = null; +}); + +modalConfirm.addEventListener('click', async () => { + if (!selectedFileName) return; + printModal.classList.add('hidden'); + try { + const res = await fetch('/api/print', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fileName: selectedFileName, levelingBeforePrint: modalLeveling.checked }), + }); + const json = await res.json(); + if (json.code === 0) { + showCtrlMsg(`Stampa avviata: ${selectedFileName}`); + await fetchStatus(); + } else { + showCtrlMsg(`Errore: ${json.message || json.code}`, false); + } + } catch (e) { + showCtrlMsg(`Errore di rete: ${e.message}`, false); + } + selectedFileName = null; +}); + +// Close modal on backdrop click +printModal.addEventListener('click', (e) => { + if (e.target === printModal) { + printModal.classList.add('hidden'); + selectedFileName = null; + } +}); + +/* ── Upload ──────────────────────────────────────────────────────────────── */ +fileInput.addEventListener('change', () => { + if (fileInput.files[0]) { + dropText.textContent = `📄 ${fileInput.files[0].name}`; + btnUpload.disabled = false; + } +}); + +// Drag & drop +dropArea.addEventListener('dragover', (e) => { e.preventDefault(); dropArea.classList.add('drag-over'); }); +dropArea.addEventListener('dragleave', () => dropArea.classList.remove('drag-over')); +dropArea.addEventListener('drop', (e) => { + e.preventDefault(); + dropArea.classList.remove('drag-over'); + const file = e.dataTransfer.files[0]; + if (file) { + const dt = new DataTransfer(); + dt.items.add(file); + fileInput.files = dt.files; + dropText.textContent = `📄 ${file.name}`; + btnUpload.disabled = false; + } +}); + +uploadForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const file = fileInput.files[0]; + if (!file) return; + + btnUpload.disabled = true; + uploadProgressWrap.classList.remove('hidden'); + setUploadPct(0); + showUploadMsg(''); + + const formData = new FormData(); + formData.append('gcodeFile', file); + formData.append('printNow', printNowChk.checked ? '1' : '0'); + formData.append('levelingBeforePrint', levelingUpload.checked ? '1' : '0'); + + // Use XMLHttpRequest for upload progress + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/upload'); + + xhr.upload.addEventListener('progress', (ev) => { + if (ev.lengthComputable) setUploadPct(Math.round((ev.loaded / ev.total) * 100)); + }); + + xhr.addEventListener('load', () => { + try { + const json = JSON.parse(xhr.responseText); + if (json.code === 0) { + showUploadMsg(`✅ Upload completato: ${file.name}`); + if (printNowChk.checked) fetchStatus(); + loadFiles(); + } else { + showUploadMsg(`❌ Errore stampante: ${json.message || json.code}`, false); + } + } catch (_) { + showUploadMsg(`❌ Risposta non valida (HTTP ${xhr.status})`, false); + } + btnUpload.disabled = false; + uploadProgressWrap.classList.add('hidden'); + }); + + xhr.addEventListener('error', () => { + showUploadMsg('❌ Errore di rete durante l\'upload.', false); + btnUpload.disabled = false; + uploadProgressWrap.classList.add('hidden'); + }); + + xhr.send(formData); +}); + +function setUploadPct(pct) { + uploadProgressBar.style.setProperty('--pct', `${pct}%`); + uploadProgressText.textContent = `${pct}%`; +} + +/* ── Boot ────────────────────────────────────────────────────────────────── */ +(async () => { + // Check configuration + try { + const cfg = await fetch('/api/config').then(r => r.json()); + if (!cfg.configured) { + badge.textContent = 'NON CONFIGURATO'; + badge.className = 'badge badge--error'; + document.querySelector('main').insertAdjacentHTML('afterbegin', + `
+ ⚠️ La stampante non è configurata. Crea il file .env nella cartella webapp/ + copiando .env.example e inserendo IP, numero di serie e CheckCode. +
` + ); + return; + } + } catch (_) { /* proceed anyway */ } + + await fetchStatus(); + pollingTimer = setInterval(fetchStatus, 4000); +})(); diff --git a/webapp/frontend/public/index.html b/webapp/frontend/public/index.html new file mode 100644 index 0000000..d673bc9 --- /dev/null +++ b/webapp/frontend/public/index.html @@ -0,0 +1,150 @@ + + + + + + FlashForge Dashboard + + + +
+
+

🖨 FlashForge Dashboard

+
+
+
+ +
+ + +
+

Camera

+
+ Camera stream +
+ Camera non disponibile +
+
+
+ + +
+
+ + +
+

Stato stampa

+ +
+
+ File + +
+
+ Progresso + +
+
+ Layer + +
+
+ Tempo rimanente + +
+
+ +
+
+
+ +
+
+ 🔥 Ugello + + +
+
+ 🛏 Piatto + + +
+
+ 📦 Camera + + +
+
+ + + +
+
+ + +
+

File in memoria

+
+ +
+
+

Premi "Aggiorna lista" per caricare i file.

+
+ + + +
+ + +
+

Carica GCode

+
+
+ + +
+
+ + +
+ +
+ +
+
+ +
+ + + + + + diff --git a/webapp/frontend/public/style.css b/webapp/frontend/public/style.css new file mode 100644 index 0000000..56dfc70 --- /dev/null +++ b/webapp/frontend/public/style.css @@ -0,0 +1,345 @@ +/* ── Reset & base ─────────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0f1117; + --surface: #1a1d27; + --surface2: #222538; + --border: #2e3150; + --text: #e4e6f1; + --text-muted: #7a7f9a; + --accent: #4f8ef7; + --success: #3ecf74; + --warning: #f0a500; + --danger: #e04c4c; + --radius: 10px; + --shadow: 0 4px 24px rgba(0,0,0,.35); + --transition: .2s ease; +} + +body { + background: var(--bg); + color: var(--text); + font-family: 'Segoe UI', system-ui, sans-serif; + font-size: 14px; + min-height: 100vh; +} + +/* ── Header ─────────────────────────────────────────────────────────────── */ +header { + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: 0 24px; + height: 56px; + display: flex; + align-items: center; +} +.header-inner { + width: 100%; + max-width: 1400px; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; +} +header h1 { font-size: 18px; font-weight: 600; letter-spacing: .5px; } + +/* ── Badge ───────────────────────────────────────────────────────────────── */ +.badge { + padding: 4px 14px; + border-radius: 20px; + font-size: 12px; + font-weight: 700; + letter-spacing: .6px; + text-transform: uppercase; +} +.badge--idle { background: rgba(122,127,154,.2); color: var(--text-muted); } +.badge--printing { background: rgba(79,142,247,.2); color: var(--accent); } +.badge--paused { background: rgba(240,165,0,.2); color: var(--warning); } +.badge--error { background: rgba(224,76,76,.2); color: var(--danger); } + +/* ── Main grid ──────────────────────────────────────────────────────────── */ +main { + max-width: 1400px; + margin: 24px auto; + padding: 0 24px; + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: auto auto; + gap: 20px; +} + +@media (max-width: 860px) { + main { grid-template-columns: 1fr; } +} + +/* ── Card ────────────────────────────────────────────────────────────────── */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + box-shadow: var(--shadow); +} +.card h2 { + font-size: 13px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-muted); + margin-bottom: 16px; +} + +/* ── Camera ─────────────────────────────────────────────────────────────── */ +.card--camera { grid-column: 1; } + +.camera-wrap { + width: 100%; + aspect-ratio: 16/9; + background: #000; + border-radius: 6px; + overflow: hidden; + position: relative; + margin-bottom: 12px; +} +#camera-img { + width: 100%; + height: 100%; + object-fit: contain; + display: none; +} +#camera-img.active { display: block; } +.camera-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 13px; +} +.camera-placeholder.hidden { display: none; } +.camera-controls { display: flex; gap: 10px; } + +/* ── Stat grid ──────────────────────────────────────────────────────────── */ +.card--status { grid-column: 2; } + +.stat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 16px; +} +.stat { + background: var(--surface2); + border-radius: 8px; + padding: 12px 14px; +} +.stat-label { + display: block; + font-size: 11px; + text-transform: uppercase; + letter-spacing: .6px; + color: var(--text-muted); + margin-bottom: 4px; +} +.stat-value { + display: block; + font-size: 18px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Progress bar ────────────────────────────────────────────────────────── */ +.progress-bar-wrap { + background: var(--surface2); + border-radius: 4px; + height: 8px; + overflow: hidden; + margin-bottom: 16px; +} +.progress-bar { + height: 100%; + width: 0%; + background: var(--accent); + border-radius: 4px; + transition: width .5s ease; +} + +/* ── Temp cards ─────────────────────────────────────────────────────────── */ +.temp-grid { + display: flex; + gap: 12px; + margin-bottom: 16px; +} +.temp-card { + flex: 1; + background: var(--surface2); + border-radius: 8px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 4px; +} +.temp-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .5px; } +.temp-value { font-size: 22px; font-weight: 700; } +.temp-target { font-size: 11px; color: var(--text-muted); } + +/* ── Print controls ─────────────────────────────────────────────────────── */ +.print-controls { display: flex; gap: 10px; flex-wrap: wrap; } +.ctrl-message { margin-top: 10px; font-size: 12px; color: var(--text-muted); min-height: 16px; } + +/* ── Buttons ─────────────────────────────────────────────────────────────── */ +.btn { + padding: 8px 18px; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: opacity var(--transition), transform var(--transition); +} +.btn:hover:not(:disabled) { opacity: .85; transform: translateY(-1px); } +.btn:active:not(:disabled) { transform: translateY(0); } +.btn:disabled { opacity: .35; cursor: not-allowed; } + +.btn--primary { background: var(--accent); color: #fff; } +.btn--secondary { background: var(--surface2); color: var(--text); border: 1px solid var(--border); } +.btn--success { background: var(--success); color: #000; } +.btn--warning { background: var(--warning); color: #000; } +.btn--danger { background: var(--danger); color: #fff; } + +/* ── File list ──────────────────────────────────────────────────────────── */ +.card--files { grid-column: 1; } + +.files-toolbar { margin-bottom: 12px; } + +.file-list { + max-height: 320px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; +} +.file-item { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 14px; + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + transition: background var(--transition); +} +.file-item:hover { background: #2a2e45; } +.file-thumb { + width: 44px; + height: 44px; + object-fit: contain; + border-radius: 4px; + background: #000; + flex-shrink: 0; +} +.file-thumb-placeholder { + width: 44px; + height: 44px; + background: var(--surface); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; +} +.file-name { flex: 1; font-size: 13px; word-break: break-all; } +.hint { color: var(--text-muted); font-size: 12px; } + +/* ── Modal ───────────────────────────────────────────────────────────────── */ +.modal { + position: fixed; + inset: 0; + background: rgba(0,0,0,.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} +.modal.hidden { display: none; } +.modal-box { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 28px; + width: 360px; + box-shadow: var(--shadow); +} +.modal-box h3 { margin-bottom: 12px; } +.modal-box p { margin-bottom: 16px; color: var(--text-muted); font-size: 13px; } +.modal-actions { display: flex; gap: 10px; margin-top: 18px; } + +/* ── Upload ─────────────────────────────────────────────────────────────── */ +.card--upload { grid-column: 2; } + +.file-drop-area { + border: 2px dashed var(--border); + border-radius: 8px; + padding: 28px 16px; + text-align: center; + cursor: pointer; + transition: border-color var(--transition), background var(--transition); + margin-bottom: 14px; +} +.file-drop-area.drag-over { + border-color: var(--accent); + background: rgba(79,142,247,.07); +} +.file-drop-area input[type="file"] { display: none; } +.file-drop-label { cursor: pointer; color: var(--text-muted); font-size: 13px; } +.file-drop-label span { display: block; } + +.upload-options { display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px; } +.checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; } +.checkbox-label input { accent-color: var(--accent); } + +.upload-progress { + margin-top: 10px; + display: flex; + align-items: center; + gap: 10px; +} +.upload-progress.hidden { display: none; } +.upload-progress-bar { + flex: 1; + height: 6px; + background: var(--surface2); + border-radius: 4px; + overflow: hidden; + position: relative; +} +.upload-progress-bar::after { + content: ''; + position: absolute; + left: 0; top: 0; bottom: 0; + width: var(--pct, 0%); + background: var(--accent); + transition: width .3s ease; +} + +/* ── Footer ─────────────────────────────────────────────────────────────── */ +footer { + border-top: 1px solid var(--border); + padding: 12px 24px; + display: flex; + justify-content: space-between; + font-size: 11px; + color: var(--text-muted); + max-width: 1400px; + margin: 0 auto; +} + +/* ── Scrollbar ───────────────────────────────────────────────────────────── */ +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } diff --git a/webapp/package-lock.json b/webapp/package-lock.json new file mode 100644 index 0000000..dc57721 --- /dev/null +++ b/webapp/package-lock.json @@ -0,0 +1,1431 @@ +{ + "name": "flashforge-webapp", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "flashforge-webapp", + "version": "1.0.0", + "dependencies": { + "dotenv": "^16.4.5", + "express": "^4.19.2", + "multer": "^1.4.5-lts.1", + "node-fetch": "^2.7.0" + }, + "devDependencies": { + "nodemon": "^3.1.4" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/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/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/webapp/package.json b/webapp/package.json new file mode 100644 index 0000000..fe785f7 --- /dev/null +++ b/webapp/package.json @@ -0,0 +1,19 @@ +{ + "name": "flashforge-webapp", + "version": "1.0.0", + "description": "Local web UI for FlashForge AD5M / AD5X printers", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "dotenv": "^16.4.5", + "express": "^4.19.2", + "multer": "^1.4.5-lts.1", + "node-fetch": "^2.7.0" + }, + "devDependencies": { + "nodemon": "^3.1.4" + } +} diff --git a/webapp/server.js b/webapp/server.js new file mode 100644 index 0000000..895b4fd --- /dev/null +++ b/webapp/server.js @@ -0,0 +1,257 @@ +'use strict'; + +require('dotenv').config(); +const express = require('express'); +const multer = require('multer'); +const fetch = require('node-fetch'); +const fs = require('fs'); +const path = require('path'); +const http = require('http'); + +const app = express(); +const PORT = process.env.PORT || 3000; +const PRINTER_IP = process.env.PRINTER_IP; +const SERIAL_NUMBER = process.env.SERIAL_NUMBER; +const CHECK_CODE = process.env.CHECK_CODE; +const PRINTER_API = `http://${PRINTER_IP}:8898`; +const CAMERA_URL = `http://${PRINTER_IP}:8080/?action=stream`; + +// ── Middleware ────────────────────────────────────────────────────────────── +app.use(express.json()); +app.use(express.static(path.join(__dirname, 'frontend', 'public'))); + +// multer: store upload in memory, then stream to printer +const upload = multer({ storage: multer.memoryStorage() }); + +// ── Helpers ───────────────────────────────────────────────────────────────── + +/** + * POST to the printer's HTTP REST API with standard auth fields. + */ +async function printerPost(endpoint, body = {}) { + const payload = { serialNumber: SERIAL_NUMBER, checkCode: CHECK_CODE, ...body }; + const res = await fetch(`${PRINTER_API}${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Printer returned ${res.status}: ${text}`); + } + return res.json(); +} + +/** + * Validate that required env vars are set and return 503 otherwise. + */ +function requireConfig(req, res, next) { + if (!PRINTER_IP || !SERIAL_NUMBER || !CHECK_CODE) { + return res.status(503).json({ + error: 'Printer not configured. Copy .env.example to .env and fill in PRINTER_IP, SERIAL_NUMBER, CHECK_CODE.', + }); + } + next(); +} + +// ── API Routes ─────────────────────────────────────────────────────────────── + +/** + * GET /api/status + * Returns the full detail response from the printer. + */ +app.get('/api/status', requireConfig, async (req, res) => { + try { + const data = await printerPost('/detail'); + res.json(data); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +/** + * POST /api/control + * Body: { action: "pause"|"resume"|"stop", jobID: "..." } + */ +app.post('/api/control', requireConfig, async (req, res) => { + const { action, jobID } = req.body; + if (!action || !jobID) { + return res.status(400).json({ error: 'action and jobID are required' }); + } + try { + const data = await printerPost('/control', { + payload: { + cmd: 'jobCtl_cmd', + args: { jobID, action }, + }, + }); + res.json(data); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +/** + * GET /api/files + * Returns the list of printable files stored on the printer. + */ +app.get('/api/files', requireConfig, async (req, res) => { + try { + const data = await printerPost('/gcodeList'); + res.json(data); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +/** + * GET /api/thumb?fileName=... + * Returns base64 thumbnail for a file. + */ +app.get('/api/thumb', requireConfig, async (req, res) => { + const { fileName } = req.query; + if (!fileName) return res.status(400).json({ error: 'fileName is required' }); + try { + const data = await printerPost('/gcodeThumb', { fileName }); + res.json(data); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +/** + * POST /api/print + * Body: { fileName: "...", levelingBeforePrint: true|false } + * Starts printing a file already stored on the printer. + */ +app.post('/api/print', requireConfig, async (req, res) => { + const { fileName, levelingBeforePrint = false } = req.body; + if (!fileName) return res.status(400).json({ error: 'fileName is required' }); + try { + const data = await printerPost('/printGcode', { fileName, levelingBeforePrint }); + res.json(data); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +/** + * POST /api/upload + * Multipart form with field "gcodeFile". + * Optional form fields: printNow (0|1), levelingBeforePrint (0|1) + */ +app.post('/api/upload', requireConfig, upload.single('gcodeFile'), async (req, res) => { + if (!req.file) return res.status(400).json({ error: 'gcodeFile is required' }); + + const printNow = req.body.printNow || '0'; + const levelingBeforePrint = req.body.levelingBeforePrint || '0'; + const fileSize = req.file.size; + + // Build a multipart body to forward to the printer + const boundary = `----FormBoundary${Date.now()}`; + const preamble = [ + `--${boundary}`, + `Content-Disposition: form-data; name="gcodeFile"; filename="${req.file.originalname}"`, + `Content-Type: application/octet-stream`, + '', + '', + ].join('\r\n'); + const epilogue = `\r\n--${boundary}--\r\n`; + + const body = Buffer.concat([ + Buffer.from(preamble), + req.file.buffer, + Buffer.from(epilogue), + ]); + + const printerRes = await fetch(`${PRINTER_API}/uploadGcode`, { + method: 'POST', + headers: { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + serialNumber: SERIAL_NUMBER, + checkCode: CHECK_CODE, + fileSize: String(fileSize), + printNow, + levelingBeforePrint, + }, + body, + }); + + const result = await printerRes.json().catch(() => ({ code: printerRes.status })); + res.status(printerRes.ok ? 200 : 502).json(result); +}); + +/** + * GET /api/camera/stream + * Proxies the MJPEG stream from the printer camera so the browser + * can display it without cross-origin issues. + */ +app.get('/api/camera/stream', requireConfig, (req, res) => { + const url = new URL(CAMERA_URL); + const options = { + hostname: url.hostname, + port: url.port || 8080, + path: url.pathname + url.search, + method: 'GET', + }; + + const proxyReq = http.request(options, (proxyRes) => { + res.writeHead(proxyRes.statusCode, { + 'Content-Type': proxyRes.headers['content-type'] || 'multipart/x-mixed-replace', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + proxyRes.pipe(res); + }); + + proxyReq.on('error', (err) => { + if (!res.headersSent) { + res.status(502).json({ error: `Camera stream error: ${err.message}` }); + } + }); + + req.on('close', () => proxyReq.destroy()); + proxyReq.end(); +}); + +/** + * POST /api/camera + * Body: { action: "open"|"close" } + * Enables or disables the camera stream on the printer. + */ +app.post('/api/camera', requireConfig, async (req, res) => { + const { action } = req.body; + if (!action) return res.status(400).json({ error: 'action is required' }); + try { + const data = await printerPost('/control', { + payload: { + cmd: 'streamCtrl_cmd', + args: { action }, + }, + }); + res.json(data); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +// ── Config check endpoint ──────────────────────────────────────────────────── +app.get('/api/config', (req, res) => { + res.json({ + configured: !!(PRINTER_IP && SERIAL_NUMBER && CHECK_CODE), + printerIp: PRINTER_IP || null, + cameraUrl: PRINTER_IP ? CAMERA_URL : null, + }); +}); + +// ── Serve frontend for all other routes ───────────────────────────────────── +app.get('*', (req, res) => { + res.sendFile(path.join(__dirname, 'frontend', 'public', 'index.html')); +}); + +app.listen(PORT, () => { + console.log(`FlashForge Web App running at http://localhost:${PORT}`); + if (!PRINTER_IP || !SERIAL_NUMBER || !CHECK_CODE) { + console.warn('⚠ PRINTER_IP, SERIAL_NUMBER or CHECK_CODE not set. Copy .env.example → .env and configure them.'); + } +}); From c581a94bcc1b5f6c54c986680b52d470fd266dee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:58:35 +0000 Subject: [PATCH 02/70] feat: add Home Assistant add-on (ha-addon/flashforge-dashboard) --- ha-addon/README.md | 84 ++++ ha-addon/flashforge-dashboard/Dockerfile | 16 + ha-addon/flashforge-dashboard/build.yaml | 7 + ha-addon/flashforge-dashboard/config.yaml | 34 ++ .../frontend/public/app.js | 401 ++++++++++++++++++ .../frontend/public/index.html | 150 +++++++ .../frontend/public/style.css | 345 +++++++++++++++ ha-addon/flashforge-dashboard/package.json | 14 + ha-addon/flashforge-dashboard/run.sh | 28 ++ ha-addon/flashforge-dashboard/server.js | 282 ++++++++++++ ha-addon/repository.yaml | 3 + 11 files changed, 1364 insertions(+) create mode 100644 ha-addon/README.md create mode 100644 ha-addon/flashforge-dashboard/Dockerfile create mode 100644 ha-addon/flashforge-dashboard/build.yaml create mode 100644 ha-addon/flashforge-dashboard/config.yaml create mode 100644 ha-addon/flashforge-dashboard/frontend/public/app.js create mode 100644 ha-addon/flashforge-dashboard/frontend/public/index.html create mode 100644 ha-addon/flashforge-dashboard/frontend/public/style.css create mode 100644 ha-addon/flashforge-dashboard/package.json create mode 100644 ha-addon/flashforge-dashboard/run.sh create mode 100644 ha-addon/flashforge-dashboard/server.js create mode 100644 ha-addon/repository.yaml diff --git a/ha-addon/README.md b/ha-addon/README.md new file mode 100644 index 0000000..e45a427 --- /dev/null +++ b/ha-addon/README.md @@ -0,0 +1,84 @@ +# FlashForge Dashboard — Home Assistant Add-on + +Add-on per Home Assistant che porta la dashboard FlashForge direttamente nella sidebar di HA. +Controlli camera, temperatura, stampa e upload GCode integrati nell'interfaccia di Home Assistant. + +## Installazione + +### 1. Aggiungi il repository + +1. In Home Assistant vai su **Impostazioni → Add-on → Store** +2. Clicca **⋮** (tre puntini in alto a destra) → **Aggiungi repository** +3. Incolla l'URL del repository: + ``` + https://github.com/MikManenti/flashforge-api-docs + ``` +4. Clicca **Aggiungi** + +### 2. Installa l'add-on + +1. Cerca **FlashForge Dashboard** nella lista degli add-on +2. Clicca sull'add-on → **Installa** +3. Attendi il completamento del download e build del container + +### 3. Configura + +Nel tab **Configurazione** dell'add-on inserisci: + +| Campo | Descrizione | +|---|---| +| `printer_ip` | Indirizzo IP della stampante in LAN (es. `192.168.1.100`) | +| `serial_number` | Numero di serie della stampante (etichetta sul retro) | +| `check_code` | CheckCode LAN — vedi sotto come trovarlo | + +#### Come trovare il CheckCode + +1. Apri l'app **FlashForge** sul telefono +2. Seleziona la stampante → **Impostazioni** → **Connessione LAN** +3. Il codice a 8 cifre mostrato è il `check_code` + +### 4. Avvia + +1. Clicca **Avvia** nel tab Info dell'add-on +2. L'add-on appare nella **sidebar di Home Assistant** sotto il nome _FlashForge_ +3. Clicca sull'icona 🖨 per aprire la dashboard + +## Funzionalità + +| Funzione | Dettaglio | +|---|---| +| 🎥 Camera live | Stream MJPEG proxato, attivazione/disattivazione in-app | +| 🌡 Temperature | Ugello, piatto e camera — valori correnti e target | +| 📊 Info stampa | Layer corrente / totale, progresso, tempo rimanente, nome file | +| ⏸ ▶ ⏹ Controlli | Pausa, Riprendi, Stop della stampa corrente | +| 📂 File in memoria | Lista file sulla stampante, thumbnail, avvio stampa | +| ⬆ Upload GCode | Carica `.gcode` / `.g` / `.gx` / `.3mf` con opzione "Stampa subito" | + +## Note tecniche + +- L'add-on usa **Ingress**: è accessibile tramite il reverse proxy di HA senza aprire porte aggiuntive sul router, ed è protetto dall'autenticazione di Home Assistant. +- La porta interna del container è `8099`. +- La camera è accessibile su `http://:8080/?action=stream` (MJPEG, nessuna autenticazione richiesta dalla stampante). +- Architetture supportate: `amd64`, `aarch64`, `armv7`, `armhf`. + +## Struttura dell'add-on + +``` +ha-addon/ +├── repository.yaml ← Descrittore repository HA +└── flashforge-dashboard/ + ├── config.yaml ← Metadati, opzioni, ingress + ├── build.yaml ← Base image multi-arch + ├── Dockerfile + ├── run.sh ← Script avvio (legge config via bashio) + ├── server.js ← Backend Express + ├── package.json + └── frontend/public/ + ├── index.html + ├── style.css + └── app.js +``` + +--- + +*Progetto non ufficiale, non affiliato con FlashForge o Home Assistant.* diff --git a/ha-addon/flashforge-dashboard/Dockerfile b/ha-addon/flashforge-dashboard/Dockerfile new file mode 100644 index 0000000..b2d162b --- /dev/null +++ b/ha-addon/flashforge-dashboard/Dockerfile @@ -0,0 +1,16 @@ +ARG BUILD_FROM +FROM $BUILD_FROM + +# Copy application +WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev --no-fund --no-audit + +COPY server.js ./ +COPY frontend/ ./frontend/ + +# Start script +COPY run.sh / +RUN chmod +x /run.sh + +CMD ["/run.sh"] diff --git a/ha-addon/flashforge-dashboard/build.yaml b/ha-addon/flashforge-dashboard/build.yaml new file mode 100644 index 0000000..e86ee03 --- /dev/null +++ b/ha-addon/flashforge-dashboard/build.yaml @@ -0,0 +1,7 @@ +build_from: + amd64: "ghcr.io/home-assistant/amd64-base-nodejs:20" + aarch64: "ghcr.io/home-assistant/aarch64-base-nodejs:20" + armv7: "ghcr.io/home-assistant/armv7-base-nodejs:20" + armhf: "ghcr.io/home-assistant/armhf-base-nodejs:20" + +squash: false diff --git a/ha-addon/flashforge-dashboard/config.yaml b/ha-addon/flashforge-dashboard/config.yaml new file mode 100644 index 0000000..4e1bb41 --- /dev/null +++ b/ha-addon/flashforge-dashboard/config.yaml @@ -0,0 +1,34 @@ +name: FlashForge Dashboard +description: >- + Dashboard locale per stampanti FlashForge AD5M / AD5X / 5M Pro. + Mostra camera live, stato di stampa, temperature, layer, controlli + pausa/riprendi/stop, lista file in memoria e upload GCode. +version: "1.0.0" +slug: flashforge_dashboard +init: false +homeassistant: "2023.1" + +arch: + - amd64 + - aarch64 + - armv7 + - armhf + +# Ingress: accessibile dalla sidebar di HA senza aprire porte extra +ingress: true +ingress_port: 8099 +panel_icon: mdi:printer-3d +panel_title: FlashForge + +# Opzioni configurabili dal pannello HA (Add-on → Configurazione) +options: + printer_ip: "" + serial_number: "" + check_code: "" + +schema: + printer_ip: str + serial_number: str + check_code: str + +url: "https://github.com/MikManenti/flashforge-api-docs" diff --git a/ha-addon/flashforge-dashboard/frontend/public/app.js b/ha-addon/flashforge-dashboard/frontend/public/app.js new file mode 100644 index 0000000..2202a7b --- /dev/null +++ b/ha-addon/flashforge-dashboard/frontend/public/app.js @@ -0,0 +1,401 @@ +'use strict'; + +// window.INGRESS_PATH is injected at runtime by server.js when running as a +// Home Assistant add-on. It is the URL prefix HA uses for the ingress proxy +// (e.g. "/api/hassio_ingress/abc123"). When running standalone it is undefined. +const BASE = (window.INGRESS_PATH || '').replace(/\/$/, ''); + +/* ── State ───────────────────────────────────────────────────────────────── */ +let currentJobID = null; +let currentStatus = null; +let pollingTimer = null; +let cameraActive = false; + +/* ── DOM refs ────────────────────────────────────────────────────────────── */ +const badge = document.getElementById('status-badge'); +const cameraImg = document.getElementById('camera-img'); +const cameraPlaceholder = document.getElementById('camera-placeholder'); +const btnCameraOn = document.getElementById('btn-camera-on'); +const btnCameraOff = document.getElementById('btn-camera-off'); +const sFname = document.getElementById('s-filename'); +const sProgress = document.getElementById('s-progress'); +const sLayer = document.getElementById('s-layer'); +const sTime = document.getElementById('s-time'); +const progressBar = document.getElementById('progress-bar'); +const tNozzle = document.getElementById('t-nozzle'); +const tNozzleTarget = document.getElementById('t-nozzle-target'); +const tBed = document.getElementById('t-bed'); +const tBedTarget = document.getElementById('t-bed-target'); +const tChamber = document.getElementById('t-chamber'); +const tChamberTarget = document.getElementById('t-chamber-target'); +const btnPause = document.getElementById('btn-pause'); +const btnResume = document.getElementById('btn-resume'); +const btnStop = document.getElementById('btn-stop'); +const ctrlMsg = document.getElementById('ctrl-message'); +const lastUpdate = document.getElementById('last-update'); + +const btnRefreshFiles = document.getElementById('btn-refresh-files'); +const fileList = document.getElementById('file-list'); +const printModal = document.getElementById('print-modal'); +const modalFilename = document.getElementById('modal-filename'); +const modalLeveling = document.getElementById('modal-leveling'); +const modalConfirm = document.getElementById('modal-confirm'); +const modalCancel = document.getElementById('modal-cancel'); + +const uploadForm = document.getElementById('upload-form'); +const fileInput = document.getElementById('file-input'); +const dropText = document.getElementById('drop-text'); +const dropArea = document.getElementById('drop-area'); +const printNowChk = document.getElementById('print-now'); +const levelingUpload = document.getElementById('leveling-upload'); +const btnUpload = document.getElementById('btn-upload'); +const uploadProgressWrap = document.getElementById('upload-progress'); +const uploadProgressBar = document.getElementById('upload-progress-bar'); +const uploadProgressText = document.getElementById('upload-progress-text'); +const uploadMessage = document.getElementById('upload-message'); + +/* ── Utilities ───────────────────────────────────────────────────────────── */ +function fmt(v, unit = '°C') { + return v !== undefined && v !== null ? `${Math.round(v)}${unit}` : '—'; +} +function fmtTime(seconds) { + if (!seconds || seconds < 0) return '—'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; +} +function showCtrlMsg(msg, ok = true) { + ctrlMsg.textContent = msg; + ctrlMsg.style.color = ok ? 'var(--success)' : 'var(--danger)'; + setTimeout(() => { ctrlMsg.textContent = ''; }, 4000); +} +function showUploadMsg(msg, ok = true) { + uploadMessage.textContent = msg; + uploadMessage.style.color = ok ? 'var(--success)' : 'var(--danger)'; +} + +/* ── Status polling ──────────────────────────────────────────────────────── */ +async function fetchStatus() { + try { + const res = await fetch(`${BASE}/api/status`); + const json = await res.json(); + if (json.detail) updateUI(json.detail); + lastUpdate.textContent = `Ultimo aggiornamento: ${new Date().toLocaleTimeString('it-IT')}`; + } catch (e) { + badge.textContent = 'Errore connessione'; + badge.className = 'badge badge--error'; + } +} + +function updateUI(d) { + currentStatus = d.status || 'IDLE'; + + // Badge + const statusMap = { + PRINTING: ['badge--printing', 'STAMPA'], + PAUSED: ['badge--paused', 'IN PAUSA'], + IDLE: ['badge--idle', 'INATTIVA'], + ERROR: ['badge--error', 'ERRORE'], + HOMING: ['badge--printing', 'HOMING'], + }; + const [cls, label] = statusMap[currentStatus] || ['badge--idle', currentStatus]; + badge.className = `badge ${cls}`; + badge.textContent = label; + + // Job info + currentJobID = d.jobInfo ? (d.jobInfo[0] && d.jobInfo[0][1]) : null; + sFname.textContent = d.printFileName || '—'; + sFname.title = d.printFileName || ''; + + const pct = d.printProgress != null ? Math.round(d.printProgress * 100) : null; + sProgress.textContent = pct !== null ? `${pct}%` : '—'; + progressBar.style.width = pct !== null ? `${pct}%` : '0%'; + + const layer = d.printLayer ?? null; + const maxLayer = d.targetPrintLayer ?? null; + sLayer.textContent = (layer !== null && maxLayer !== null) + ? `${layer} / ${maxLayer}` + : (layer !== null ? String(layer) : '—'); + + sTime.textContent = fmtTime(d.estimatedTime); + + // Temperatures + tNozzle.textContent = fmt(d.rightTemp); + tNozzleTarget.textContent = d.rightTargetTemp ? `→ ${fmt(d.rightTargetTemp)}` : ''; + tBed.textContent = fmt(d.platTemp); + tBedTarget.textContent = d.platTargetTemp ? `→ ${fmt(d.platTargetTemp)}` : ''; + tChamber.textContent = fmt(d.chamberTemp); + tChamberTarget.textContent = d.chamberTargetTemp ? `→ ${fmt(d.chamberTargetTemp)}` : ''; + + // Controls enable/disable + const isPrinting = currentStatus === 'PRINTING'; + const isPaused = currentStatus === 'PAUSED'; + btnPause.disabled = !isPrinting; + btnResume.disabled = !isPaused; + btnStop.disabled = !(isPrinting || isPaused); + + // Camera: if the printer reports a stream URL, auto-enable + if (d.cameraStreamUrl && !cameraActive) { + enableCamera(); + } +} + +/* ── Camera ──────────────────────────────────────────────────────────────── */ +function enableCamera() { + cameraImg.src = `${BASE}/api/camera/stream?t=${Date.now()}`; + cameraImg.classList.add('active'); + cameraPlaceholder.classList.add('hidden'); + cameraActive = true; +} +function disableCamera() { + cameraImg.src = ''; + cameraImg.classList.remove('active'); + cameraPlaceholder.classList.remove('hidden'); + cameraActive = false; +} + +btnCameraOn.addEventListener('click', async () => { + try { + await fetch(`${BASE}/api/camera`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'open' }) }); + } catch (_) { /* ignore – try to show stream anyway */ } + enableCamera(); +}); + +btnCameraOff.addEventListener('click', async () => { + disableCamera(); + try { + await fetch(`${BASE}/api/camera`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'close' }) }); + } catch (_) { /* ignore */ } +}); + +cameraImg.addEventListener('error', () => { + disableCamera(); +}); + +/* ── Print controls ──────────────────────────────────────────────────────── */ +async function sendControl(action) { + if (!currentJobID) { showCtrlMsg('Nessun job attivo.', false); return; } + try { + const res = await fetch(`${BASE}/api/control`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action, jobID: currentJobID }), + }); + const json = await res.json(); + if (json.code === 0) { + showCtrlMsg(`Comando "${action}" inviato.`); + await fetchStatus(); + } else { + showCtrlMsg(`Errore: ${json.message || json.code}`, false); + } + } catch (e) { + showCtrlMsg(`Errore di rete: ${e.message}`, false); + } +} + +btnPause.addEventListener('click', () => sendControl('pause')); +btnResume.addEventListener('click', () => sendControl('resume')); +btnStop.addEventListener('click', async () => { + if (!confirm('Sei sicuro di voler interrompere la stampa?')) return; + await sendControl('stop'); +}); + +/* ── File list ───────────────────────────────────────────────────────────── */ +let selectedFileName = null; + +btnRefreshFiles.addEventListener('click', loadFiles); + +async function loadFiles() { + fileList.innerHTML = '

Caricamento…

'; + try { + const res = await fetch(`${BASE}/api/files`); + const json = await res.json(); + const files = json.gcodeList || []; + if (!files.length) { + fileList.innerHTML = '

Nessun file trovato nella stampante.

'; + return; + } + fileList.innerHTML = ''; + files.forEach(renderFileItem); + } catch (e) { + fileList.innerHTML = `

Errore: ${e.message}

`; + } +} + +async function renderFileItem(fileName) { + const item = document.createElement('div'); + item.className = 'file-item'; + + // Thumb + const thumbWrap = document.createElement('div'); + thumbWrap.className = 'file-thumb-placeholder'; + thumbWrap.textContent = '📄'; + item.appendChild(thumbWrap); + + const nameEl = document.createElement('span'); + nameEl.className = 'file-name'; + nameEl.textContent = fileName; + item.appendChild(nameEl); + + item.addEventListener('click', () => openPrintModal(fileName)); + fileList.appendChild(item); + + // Async thumb load + try { + const res = await fetch(`${BASE}/api/thumb?fileName=${encodeURIComponent(fileName)}`); + const json = await res.json(); + if (json.imageData) { + const img = document.createElement('img'); + img.className = 'file-thumb'; + img.src = `data:image/png;base64,${json.imageData}`; + img.alt = fileName; + thumbWrap.replaceWith(img); + } + } catch (_) { /* keep placeholder */ } +} + +function openPrintModal(fileName) { + selectedFileName = fileName; + modalFilename.textContent = fileName; + modalLeveling.checked = false; + printModal.classList.remove('hidden'); +} + +modalCancel.addEventListener('click', () => { + printModal.classList.add('hidden'); + selectedFileName = null; +}); + +modalConfirm.addEventListener('click', async () => { + if (!selectedFileName) return; + printModal.classList.add('hidden'); + try { + const res = await fetch(`${BASE}/api/print`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fileName: selectedFileName, levelingBeforePrint: modalLeveling.checked }), + }); + const json = await res.json(); + if (json.code === 0) { + showCtrlMsg(`Stampa avviata: ${selectedFileName}`); + await fetchStatus(); + } else { + showCtrlMsg(`Errore: ${json.message || json.code}`, false); + } + } catch (e) { + showCtrlMsg(`Errore di rete: ${e.message}`, false); + } + selectedFileName = null; +}); + +// Close modal on backdrop click +printModal.addEventListener('click', (e) => { + if (e.target === printModal) { + printModal.classList.add('hidden'); + selectedFileName = null; + } +}); + +/* ── Upload ──────────────────────────────────────────────────────────────── */ +fileInput.addEventListener('change', () => { + if (fileInput.files[0]) { + dropText.textContent = `📄 ${fileInput.files[0].name}`; + btnUpload.disabled = false; + } +}); + +// Drag & drop +dropArea.addEventListener('dragover', (e) => { e.preventDefault(); dropArea.classList.add('drag-over'); }); +dropArea.addEventListener('dragleave', () => dropArea.classList.remove('drag-over')); +dropArea.addEventListener('drop', (e) => { + e.preventDefault(); + dropArea.classList.remove('drag-over'); + const file = e.dataTransfer.files[0]; + if (file) { + const dt = new DataTransfer(); + dt.items.add(file); + fileInput.files = dt.files; + dropText.textContent = `📄 ${file.name}`; + btnUpload.disabled = false; + } +}); + +uploadForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const file = fileInput.files[0]; + if (!file) return; + + btnUpload.disabled = true; + uploadProgressWrap.classList.remove('hidden'); + setUploadPct(0); + showUploadMsg(''); + + const formData = new FormData(); + formData.append('gcodeFile', file); + formData.append('printNow', printNowChk.checked ? '1' : '0'); + formData.append('levelingBeforePrint', levelingUpload.checked ? '1' : '0'); + + // Use XMLHttpRequest for upload progress + const xhr = new XMLHttpRequest(); + xhr.open('POST', `${BASE}/api/upload`); + + xhr.upload.addEventListener('progress', (ev) => { + if (ev.lengthComputable) setUploadPct(Math.round((ev.loaded / ev.total) * 100)); + }); + + xhr.addEventListener('load', () => { + try { + const json = JSON.parse(xhr.responseText); + if (json.code === 0) { + showUploadMsg(`✅ Upload completato: ${file.name}`); + if (printNowChk.checked) fetchStatus(); + loadFiles(); + } else { + showUploadMsg(`❌ Errore stampante: ${json.message || json.code}`, false); + } + } catch (_) { + showUploadMsg(`❌ Risposta non valida (HTTP ${xhr.status})`, false); + } + btnUpload.disabled = false; + uploadProgressWrap.classList.add('hidden'); + }); + + xhr.addEventListener('error', () => { + showUploadMsg('❌ Errore di rete durante l\'upload.', false); + btnUpload.disabled = false; + uploadProgressWrap.classList.add('hidden'); + }); + + xhr.send(formData); +}); + +function setUploadPct(pct) { + uploadProgressBar.style.setProperty('--pct', `${pct}%`); + uploadProgressText.textContent = `${pct}%`; +} + +/* ── Boot ────────────────────────────────────────────────────────────────── */ +(async () => { + // Check configuration + try { + const cfg = await fetch(`${BASE}/api/config`).then(r => r.json()); + if (!cfg.configured) { + badge.textContent = 'NON CONFIGURATO'; + badge.className = 'badge badge--error'; + document.querySelector('main').insertAdjacentHTML('afterbegin', + `
+ ⚠️ La stampante non è configurata. + Vai su Impostazioni → Add-on → FlashForge Dashboard → Configurazione + e inserisci printer_ip, serial_number e check_code. +
` + ); + return; + } + } catch (_) { /* proceed anyway */ } + + await fetchStatus(); + pollingTimer = setInterval(fetchStatus, 4000); +})(); diff --git a/ha-addon/flashforge-dashboard/frontend/public/index.html b/ha-addon/flashforge-dashboard/frontend/public/index.html new file mode 100644 index 0000000..d673bc9 --- /dev/null +++ b/ha-addon/flashforge-dashboard/frontend/public/index.html @@ -0,0 +1,150 @@ + + + + + + FlashForge Dashboard + + + +
+
+

🖨 FlashForge Dashboard

+
+
+
+ +
+ + +
+

Camera

+
+ Camera stream +
+ Camera non disponibile +
+
+
+ + +
+
+ + +
+

Stato stampa

+ +
+
+ File + +
+
+ Progresso + +
+
+ Layer + +
+
+ Tempo rimanente + +
+
+ +
+
+
+ +
+
+ 🔥 Ugello + + +
+
+ 🛏 Piatto + + +
+
+ 📦 Camera + + +
+
+ + + +
+
+ + +
+

File in memoria

+
+ +
+
+

Premi "Aggiorna lista" per caricare i file.

+
+ + + +
+ + +
+

Carica GCode

+
+
+ + +
+
+ + +
+ +
+ +
+
+ +
+ + + + + + diff --git a/ha-addon/flashforge-dashboard/frontend/public/style.css b/ha-addon/flashforge-dashboard/frontend/public/style.css new file mode 100644 index 0000000..56dfc70 --- /dev/null +++ b/ha-addon/flashforge-dashboard/frontend/public/style.css @@ -0,0 +1,345 @@ +/* ── Reset & base ─────────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0f1117; + --surface: #1a1d27; + --surface2: #222538; + --border: #2e3150; + --text: #e4e6f1; + --text-muted: #7a7f9a; + --accent: #4f8ef7; + --success: #3ecf74; + --warning: #f0a500; + --danger: #e04c4c; + --radius: 10px; + --shadow: 0 4px 24px rgba(0,0,0,.35); + --transition: .2s ease; +} + +body { + background: var(--bg); + color: var(--text); + font-family: 'Segoe UI', system-ui, sans-serif; + font-size: 14px; + min-height: 100vh; +} + +/* ── Header ─────────────────────────────────────────────────────────────── */ +header { + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: 0 24px; + height: 56px; + display: flex; + align-items: center; +} +.header-inner { + width: 100%; + max-width: 1400px; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; +} +header h1 { font-size: 18px; font-weight: 600; letter-spacing: .5px; } + +/* ── Badge ───────────────────────────────────────────────────────────────── */ +.badge { + padding: 4px 14px; + border-radius: 20px; + font-size: 12px; + font-weight: 700; + letter-spacing: .6px; + text-transform: uppercase; +} +.badge--idle { background: rgba(122,127,154,.2); color: var(--text-muted); } +.badge--printing { background: rgba(79,142,247,.2); color: var(--accent); } +.badge--paused { background: rgba(240,165,0,.2); color: var(--warning); } +.badge--error { background: rgba(224,76,76,.2); color: var(--danger); } + +/* ── Main grid ──────────────────────────────────────────────────────────── */ +main { + max-width: 1400px; + margin: 24px auto; + padding: 0 24px; + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: auto auto; + gap: 20px; +} + +@media (max-width: 860px) { + main { grid-template-columns: 1fr; } +} + +/* ── Card ────────────────────────────────────────────────────────────────── */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + box-shadow: var(--shadow); +} +.card h2 { + font-size: 13px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-muted); + margin-bottom: 16px; +} + +/* ── Camera ─────────────────────────────────────────────────────────────── */ +.card--camera { grid-column: 1; } + +.camera-wrap { + width: 100%; + aspect-ratio: 16/9; + background: #000; + border-radius: 6px; + overflow: hidden; + position: relative; + margin-bottom: 12px; +} +#camera-img { + width: 100%; + height: 100%; + object-fit: contain; + display: none; +} +#camera-img.active { display: block; } +.camera-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 13px; +} +.camera-placeholder.hidden { display: none; } +.camera-controls { display: flex; gap: 10px; } + +/* ── Stat grid ──────────────────────────────────────────────────────────── */ +.card--status { grid-column: 2; } + +.stat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 16px; +} +.stat { + background: var(--surface2); + border-radius: 8px; + padding: 12px 14px; +} +.stat-label { + display: block; + font-size: 11px; + text-transform: uppercase; + letter-spacing: .6px; + color: var(--text-muted); + margin-bottom: 4px; +} +.stat-value { + display: block; + font-size: 18px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Progress bar ────────────────────────────────────────────────────────── */ +.progress-bar-wrap { + background: var(--surface2); + border-radius: 4px; + height: 8px; + overflow: hidden; + margin-bottom: 16px; +} +.progress-bar { + height: 100%; + width: 0%; + background: var(--accent); + border-radius: 4px; + transition: width .5s ease; +} + +/* ── Temp cards ─────────────────────────────────────────────────────────── */ +.temp-grid { + display: flex; + gap: 12px; + margin-bottom: 16px; +} +.temp-card { + flex: 1; + background: var(--surface2); + border-radius: 8px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 4px; +} +.temp-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .5px; } +.temp-value { font-size: 22px; font-weight: 700; } +.temp-target { font-size: 11px; color: var(--text-muted); } + +/* ── Print controls ─────────────────────────────────────────────────────── */ +.print-controls { display: flex; gap: 10px; flex-wrap: wrap; } +.ctrl-message { margin-top: 10px; font-size: 12px; color: var(--text-muted); min-height: 16px; } + +/* ── Buttons ─────────────────────────────────────────────────────────────── */ +.btn { + padding: 8px 18px; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: opacity var(--transition), transform var(--transition); +} +.btn:hover:not(:disabled) { opacity: .85; transform: translateY(-1px); } +.btn:active:not(:disabled) { transform: translateY(0); } +.btn:disabled { opacity: .35; cursor: not-allowed; } + +.btn--primary { background: var(--accent); color: #fff; } +.btn--secondary { background: var(--surface2); color: var(--text); border: 1px solid var(--border); } +.btn--success { background: var(--success); color: #000; } +.btn--warning { background: var(--warning); color: #000; } +.btn--danger { background: var(--danger); color: #fff; } + +/* ── File list ──────────────────────────────────────────────────────────── */ +.card--files { grid-column: 1; } + +.files-toolbar { margin-bottom: 12px; } + +.file-list { + max-height: 320px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; +} +.file-item { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 14px; + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + transition: background var(--transition); +} +.file-item:hover { background: #2a2e45; } +.file-thumb { + width: 44px; + height: 44px; + object-fit: contain; + border-radius: 4px; + background: #000; + flex-shrink: 0; +} +.file-thumb-placeholder { + width: 44px; + height: 44px; + background: var(--surface); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; +} +.file-name { flex: 1; font-size: 13px; word-break: break-all; } +.hint { color: var(--text-muted); font-size: 12px; } + +/* ── Modal ───────────────────────────────────────────────────────────────── */ +.modal { + position: fixed; + inset: 0; + background: rgba(0,0,0,.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} +.modal.hidden { display: none; } +.modal-box { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 28px; + width: 360px; + box-shadow: var(--shadow); +} +.modal-box h3 { margin-bottom: 12px; } +.modal-box p { margin-bottom: 16px; color: var(--text-muted); font-size: 13px; } +.modal-actions { display: flex; gap: 10px; margin-top: 18px; } + +/* ── Upload ─────────────────────────────────────────────────────────────── */ +.card--upload { grid-column: 2; } + +.file-drop-area { + border: 2px dashed var(--border); + border-radius: 8px; + padding: 28px 16px; + text-align: center; + cursor: pointer; + transition: border-color var(--transition), background var(--transition); + margin-bottom: 14px; +} +.file-drop-area.drag-over { + border-color: var(--accent); + background: rgba(79,142,247,.07); +} +.file-drop-area input[type="file"] { display: none; } +.file-drop-label { cursor: pointer; color: var(--text-muted); font-size: 13px; } +.file-drop-label span { display: block; } + +.upload-options { display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px; } +.checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; } +.checkbox-label input { accent-color: var(--accent); } + +.upload-progress { + margin-top: 10px; + display: flex; + align-items: center; + gap: 10px; +} +.upload-progress.hidden { display: none; } +.upload-progress-bar { + flex: 1; + height: 6px; + background: var(--surface2); + border-radius: 4px; + overflow: hidden; + position: relative; +} +.upload-progress-bar::after { + content: ''; + position: absolute; + left: 0; top: 0; bottom: 0; + width: var(--pct, 0%); + background: var(--accent); + transition: width .3s ease; +} + +/* ── Footer ─────────────────────────────────────────────────────────────── */ +footer { + border-top: 1px solid var(--border); + padding: 12px 24px; + display: flex; + justify-content: space-between; + font-size: 11px; + color: var(--text-muted); + max-width: 1400px; + margin: 0 auto; +} + +/* ── Scrollbar ───────────────────────────────────────────────────────────── */ +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } diff --git a/ha-addon/flashforge-dashboard/package.json b/ha-addon/flashforge-dashboard/package.json new file mode 100644 index 0000000..e0cc972 --- /dev/null +++ b/ha-addon/flashforge-dashboard/package.json @@ -0,0 +1,14 @@ +{ + "name": "flashforge-dashboard", + "version": "1.0.0", + "description": "Home Assistant add-on: FlashForge 3D printer dashboard", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.19.2", + "multer": "^1.4.5-lts.1", + "node-fetch": "^2.7.0" + } +} diff --git a/ha-addon/flashforge-dashboard/run.sh b/ha-addon/flashforge-dashboard/run.sh new file mode 100644 index 0000000..3632d49 --- /dev/null +++ b/ha-addon/flashforge-dashboard/run.sh @@ -0,0 +1,28 @@ +#!/usr/bin/with-contenv bashio +# ============================================================================== +# FlashForge Dashboard – Home Assistant add-on start script +# Reads options from /data/options.json via bashio and launches the Node server. +# ============================================================================== + +bashio::log.info "Starting FlashForge Dashboard..." + +export PRINTER_IP="$(bashio::config 'printer_ip')" +export SERIAL_NUMBER="$(bashio::config 'serial_number')" +export CHECK_CODE="$(bashio::config 'check_code')" +export PORT="8099" +export NODE_ENV="production" + +if bashio::config.is_empty 'printer_ip'; then + bashio::log.warning "printer_ip is not configured. Set it in the add-on Configuration tab." +fi +if bashio::config.is_empty 'serial_number'; then + bashio::log.warning "serial_number is not configured. Set it in the add-on Configuration tab." +fi +if bashio::config.is_empty 'check_code'; then + bashio::log.warning "check_code is not configured. Set it in the add-on Configuration tab." +fi + +bashio::log.info "Printer IP: ${PRINTER_IP}" +bashio::log.info "Listening on port ${PORT}" + +exec node /app/server.js diff --git a/ha-addon/flashforge-dashboard/server.js b/ha-addon/flashforge-dashboard/server.js new file mode 100644 index 0000000..fef1f1d --- /dev/null +++ b/ha-addon/flashforge-dashboard/server.js @@ -0,0 +1,282 @@ +'use strict'; + +// Note: no dotenv — environment variables are injected by run.sh via bashio. + +const express = require('express'); +const multer = require('multer'); +const fetch = require('node-fetch'); +const fs = require('fs'); +const path = require('path'); +const http = require('http'); + +const app = express(); +const PORT = process.env.PORT || 8099; +const PRINTER_IP = process.env.PRINTER_IP; +const SERIAL_NUMBER = process.env.SERIAL_NUMBER; +const CHECK_CODE = process.env.CHECK_CODE; + +// HA Ingress sets this env var to the URL prefix it uses when proxying +// (e.g. "/api/hassio_ingress/abc123"). The frontend needs this to build +// correct absolute URLs for fetch() calls and the camera stream. +const INGRESS_PATH = (process.env.INGRESS_PATH || '').replace(/\/$/, ''); + +const PRINTER_API = `http://${PRINTER_IP}:8898`; +const CAMERA_URL = `http://${PRINTER_IP}:8080/?action=stream`; + +// ── Middleware ────────────────────────────────────────────────────────────── +app.use(express.json()); +app.use(express.static(path.join(__dirname, 'frontend', 'public'))); + +// multer: store upload in memory, then stream to printer +const upload = multer({ storage: multer.memoryStorage() }); + +// ── Helpers ───────────────────────────────────────────────────────────────── + +/** + * POST to the printer's HTTP REST API with standard auth fields. + */ +async function printerPost(endpoint, body = {}) { + const payload = { serialNumber: SERIAL_NUMBER, checkCode: CHECK_CODE, ...body }; + const res = await fetch(`${PRINTER_API}${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Printer returned ${res.status}: ${text}`); + } + return res.json(); +} + +/** + * Validate that required env vars are set and return 503 otherwise. + */ +function requireConfig(req, res, next) { + if (!PRINTER_IP || !SERIAL_NUMBER || !CHECK_CODE) { + return res.status(503).json({ + error: 'Printer not configured. Set printer_ip, serial_number and check_code in the add-on Configuration tab.', + }); + } + next(); +} + +// ── API Routes ─────────────────────────────────────────────────────────────── + +/** + * GET /api/status + * Returns the full detail response from the printer. + */ +app.get('/api/status', requireConfig, async (req, res) => { + try { + const data = await printerPost('/detail'); + res.json(data); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +/** + * POST /api/control + * Body: { action: "pause"|"resume"|"stop", jobID: "..." } + */ +app.post('/api/control', requireConfig, async (req, res) => { + const { action, jobID } = req.body; + if (!action || !jobID) { + return res.status(400).json({ error: 'action and jobID are required' }); + } + try { + const data = await printerPost('/control', { + payload: { + cmd: 'jobCtl_cmd', + args: { jobID, action }, + }, + }); + res.json(data); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +/** + * GET /api/files + * Returns the list of printable files stored on the printer. + */ +app.get('/api/files', requireConfig, async (req, res) => { + try { + const data = await printerPost('/gcodeList'); + res.json(data); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +/** + * GET /api/thumb?fileName=... + * Returns base64 thumbnail for a file. + */ +app.get('/api/thumb', requireConfig, async (req, res) => { + const { fileName } = req.query; + if (!fileName) return res.status(400).json({ error: 'fileName is required' }); + try { + const data = await printerPost('/gcodeThumb', { fileName }); + res.json(data); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +/** + * POST /api/print + * Body: { fileName: "...", levelingBeforePrint: true|false } + * Starts printing a file already stored on the printer. + */ +app.post('/api/print', requireConfig, async (req, res) => { + const { fileName, levelingBeforePrint = false } = req.body; + if (!fileName) return res.status(400).json({ error: 'fileName is required' }); + try { + const data = await printerPost('/printGcode', { fileName, levelingBeforePrint }); + res.json(data); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +/** + * POST /api/upload + * Multipart form with field "gcodeFile". + * Optional form fields: printNow (0|1), levelingBeforePrint (0|1) + */ +app.post('/api/upload', requireConfig, upload.single('gcodeFile'), async (req, res) => { + if (!req.file) return res.status(400).json({ error: 'gcodeFile is required' }); + + const printNow = req.body.printNow || '0'; + const levelingBeforePrint = req.body.levelingBeforePrint || '0'; + const fileSize = req.file.size; + + // Build a multipart body to forward to the printer + const boundary = `----FormBoundary${Date.now()}`; + const preamble = [ + `--${boundary}`, + `Content-Disposition: form-data; name="gcodeFile"; filename="${req.file.originalname}"`, + `Content-Type: application/octet-stream`, + '', + '', + ].join('\r\n'); + const epilogue = `\r\n--${boundary}--\r\n`; + + const body = Buffer.concat([ + Buffer.from(preamble), + req.file.buffer, + Buffer.from(epilogue), + ]); + + const printerRes = await fetch(`${PRINTER_API}/uploadGcode`, { + method: 'POST', + headers: { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + serialNumber: SERIAL_NUMBER, + checkCode: CHECK_CODE, + fileSize: String(fileSize), + printNow, + levelingBeforePrint, + }, + body, + }); + + const result = await printerRes.json().catch(() => ({ code: printerRes.status })); + res.status(printerRes.ok ? 200 : 502).json(result); +}); + +/** + * GET /api/camera/stream + * Proxies the MJPEG stream from the printer camera so the browser + * can display it without cross-origin issues. + */ +app.get('/api/camera/stream', requireConfig, (req, res) => { + const url = new URL(CAMERA_URL); + const options = { + hostname: url.hostname, + port: url.port || 8080, + path: url.pathname + url.search, + method: 'GET', + }; + + const proxyReq = http.request(options, (proxyRes) => { + res.writeHead(proxyRes.statusCode, { + 'Content-Type': proxyRes.headers['content-type'] || 'multipart/x-mixed-replace', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + proxyRes.pipe(res); + }); + + proxyReq.on('error', (err) => { + if (!res.headersSent) { + res.status(502).json({ error: `Camera stream error: ${err.message}` }); + } + }); + + req.on('close', () => proxyReq.destroy()); + proxyReq.end(); +}); + +/** + * POST /api/camera + * Body: { action: "open"|"close" } + * Enables or disables the camera stream on the printer. + */ +app.post('/api/camera', requireConfig, async (req, res) => { + const { action } = req.body; + if (!action) return res.status(400).json({ error: 'action is required' }); + try { + const data = await printerPost('/control', { + payload: { + cmd: 'streamCtrl_cmd', + args: { action }, + }, + }); + res.json(data); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +// ── Config check endpoint ──────────────────────────────────────────────────── +app.get('/api/config', (req, res) => { + res.json({ + configured: !!(PRINTER_IP && SERIAL_NUMBER && CHECK_CODE), + printerIp: PRINTER_IP || null, + cameraUrl: PRINTER_IP ? CAMERA_URL : null, + ingressPath: INGRESS_PATH, + }); +}); + +// ── Serve index.html dynamically with injected INGRESS_PATH ───────────────── +// HA Ingress strips the path prefix before forwarding requests, so all backend +// routes work at /api/... as normal. However, browser-side fetch() calls use +// absolute paths (e.g. /api/status) which would bypass the ingress prefix. +// We inject window.INGRESS_PATH into the HTML so the frontend can prefix them. +const INDEX_HTML_PATH = path.join(__dirname, 'frontend', 'public', 'index.html'); +let indexHtmlBase = null; + +function serveIndex(req, res) { + if (!indexHtmlBase) { + indexHtmlBase = fs.readFileSync(INDEX_HTML_PATH, 'utf8'); + } + const script = `\n`; + const html = indexHtmlBase.replace('', ` ${script}`); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(html); +} + +app.get('*', serveIndex); + +// ── Start ──────────────────────────────────────────────────────────────────── +app.listen(PORT, () => { + console.log(`FlashForge Dashboard (HA add-on) running on port ${PORT}`); + console.log(`Ingress path: ${INGRESS_PATH || '(none)'}`); + if (!PRINTER_IP || !SERIAL_NUMBER || !CHECK_CODE) { + console.warn('⚠ printer_ip, serial_number or check_code not set. Configure them in the HA add-on Configuration tab.'); + } +}); diff --git a/ha-addon/repository.yaml b/ha-addon/repository.yaml new file mode 100644 index 0000000..c0c77ae --- /dev/null +++ b/ha-addon/repository.yaml @@ -0,0 +1,3 @@ +name: FlashForge Add-ons +url: https://github.com/MikManenti/flashforge-api-docs +maintainer: MikManenti From 242da7ebb740a1e8ab2597a18210e28221f01b7a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:34:22 +0000 Subject: [PATCH 03/70] refactor: convert repo to Home Assistant add-on layout --- .claude/agents/flashforge-docs-writer.md | 86 - .claude/skills/sub-agent-creator/SKILL.md | 184 --- .../references/agent-creation-prompt.md | 80 - .../references/sub-agents-docs.md | 295 ---- .../scripts/validate_agent.py | 202 --- .gitignore | 4 +- README.md | 105 +- endpoints/endpoints_5m_3.2.7.yaml | 821 ---------- endpoints/endpoints_ad5x_1.1.7.yaml | 1060 ------------ endpoints/endpoints_ad5x_1.2.1.yaml | 1060 ------------ .../networkserver_commands_adventurer3.yaml | 1244 -------------- .../networkserver_commands_adventurer4.yaml | 239 --- .../Dockerfile | 0 .../build.yaml | 0 .../config.yaml | 0 .../frontend/public/app.js | 0 .../frontend/public/index.html | 0 .../frontend/public/style.css | 0 .../package.json | 0 .../run.sh | 0 .../server.js | 0 ha-addon/README.md | 84 - ha-addon/repository.yaml => repository.yaml | 0 webapp/.env.example | 16 - webapp/.gitignore | 3 - webapp/README.md | 115 -- webapp/frontend/public/app.js | 395 ----- webapp/frontend/public/index.html | 150 -- webapp/frontend/public/style.css | 345 ---- webapp/package-lock.json | 1431 ----------------- webapp/package.json | 19 - webapp/server.js | 257 --- 32 files changed, 36 insertions(+), 8159 deletions(-) delete mode 100644 .claude/agents/flashforge-docs-writer.md delete mode 100644 .claude/skills/sub-agent-creator/SKILL.md delete mode 100644 .claude/skills/sub-agent-creator/references/agent-creation-prompt.md delete mode 100644 .claude/skills/sub-agent-creator/references/sub-agents-docs.md delete mode 100644 .claude/skills/sub-agent-creator/scripts/validate_agent.py delete mode 100644 endpoints/endpoints_5m_3.2.7.yaml delete mode 100644 endpoints/endpoints_ad5x_1.1.7.yaml delete mode 100644 endpoints/endpoints_ad5x_1.2.1.yaml delete mode 100644 endpoints/networkserver_commands_adventurer3.yaml delete mode 100644 endpoints/networkserver_commands_adventurer4.yaml rename {ha-addon/flashforge-dashboard => flashforge-dashboard}/Dockerfile (100%) rename {ha-addon/flashforge-dashboard => flashforge-dashboard}/build.yaml (100%) rename {ha-addon/flashforge-dashboard => flashforge-dashboard}/config.yaml (100%) rename {ha-addon/flashforge-dashboard => flashforge-dashboard}/frontend/public/app.js (100%) rename {ha-addon/flashforge-dashboard => flashforge-dashboard}/frontend/public/index.html (100%) rename {ha-addon/flashforge-dashboard => flashforge-dashboard}/frontend/public/style.css (100%) rename {ha-addon/flashforge-dashboard => flashforge-dashboard}/package.json (100%) rename {ha-addon/flashforge-dashboard => flashforge-dashboard}/run.sh (100%) rename {ha-addon/flashforge-dashboard => flashforge-dashboard}/server.js (100%) delete mode 100644 ha-addon/README.md rename ha-addon/repository.yaml => repository.yaml (100%) delete mode 100644 webapp/.env.example delete mode 100644 webapp/.gitignore delete mode 100644 webapp/README.md delete mode 100644 webapp/frontend/public/app.js delete mode 100644 webapp/frontend/public/index.html delete mode 100644 webapp/frontend/public/style.css delete mode 100644 webapp/package-lock.json delete mode 100644 webapp/package.json delete mode 100644 webapp/server.js diff --git a/.claude/agents/flashforge-docs-writer.md b/.claude/agents/flashforge-docs-writer.md deleted file mode 100644 index 47fe423..0000000 --- a/.claude/agents/flashforge-docs-writer.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -name: flashforge-docs-writer -description: Professional documentation writer for FlashForge API codebases. Use proactively when API documentation needs creation, updating, or refinement. -model: inherit ---- - -You are a professional documentation writer specializing in FlashForge API documentation. Your expertise encompasses both technical API documentation and user-facing documentation for FlashForge printer systems. - -## Your Expertise - -### FlashForge Product Knowledge -- **AD5X**: Special multi-color/multi-material printer with IFS (Interchangeable Filament System) for 4 slots. No built-in camera or factory-installed LEDs. No factory filtration support. -- **Adventurer 5M**: Base model with no camera, no factory LEDs, no filtration control. -- **Adventurer 5M Pro**: Only model with built-in camera and factory-installed LEDs. Features internal filtration and TVOC level reporting. -- All three models support aftermarket camera installation (OEM or third-party) and LED upgrades. - -### Documentation Standards -- Maintain a professional, formal, and informative tone -- Never use emojis in any documentation -- Follow consistent formatting and structure -- Ensure technical accuracy and clarity - -## When Invoked - -1. **Review existing documentation** in the codebase to understand current structure and style -2. **Identify documentation gaps** and areas needing updates -3. **Create or update documentation** including: - - API reference documentation - - Usage examples and tutorials - - Technical specifications - - Printer-specific documentation - - README files - - Change logs and release notes - -## Methodology - -### Documentation Assessment -- Scan the codebase for existing documentation files -- Analyze API endpoints and functionality -- Understand the target audience (developers, users, administrators) -- Identify documentation priorities and scope - -### Writing Process -- Maintain consistent terminology and naming conventions -- Use clear, concise language appropriate for technical documentation -- Structure content logically with proper headings and sections -- Include code examples where helpful for API documentation -- Ensure all printer-specific details are accurately represented - -### Quality Assurance -- Verify all technical specifications are correct -- Cross-reference with actual code implementation -- Ensure documentation is up-to-date with code changes -- Maintain documentation version consistency - -## Documentation Types - -### API Documentation -- Detailed endpoint descriptions -- Request/response schemas -- Authentication requirements -- Error handling and status codes -- Usage examples with sample code - -### Product Documentation -- Printer-specific features and capabilities -- Hardware specifications -- Installation and setup guides -- Troubleshooting information - -### Technical Guides -- Integration instructions -- Best practices -- Configuration options -- Performance optimization - -## Best Practices - -- Use markdown formatting for structure and readability -- Include version information for all documentation -- Provide clear navigation and cross-references -- Update documentation when code changes occur -- Maintain a documentation index or table of contents -- Ensure consistency across all documentation files - -Focus on delivering comprehensive, accurate documentation that serves both technical users and end-users of the FlashForge API systems. \ No newline at end of file diff --git a/.claude/skills/sub-agent-creator/SKILL.md b/.claude/skills/sub-agent-creator/SKILL.md deleted file mode 100644 index fda84e5..0000000 --- a/.claude/skills/sub-agent-creator/SKILL.md +++ /dev/null @@ -1,184 +0,0 @@ ---- -name: sub-agent-creator -description: Interactive sub-agent creation skill for Claude Code. Use when user wants to create a custom subagent or mentions needing a specialized agent for specific tasks. This skill guides the entire subagent creation process including identifier design, system prompt generation, skill/context selection, and writing properly formatted agent files to .claude/agents. ---- - -# Sub-Agent Creator - -Interactive skill for creating Claude Code subagents. Guides the complete process: gathering requirements, designing the agent's purpose and persona, selecting helpful skills and documentation, and writing a properly formatted agent file to `.claude/agents/`. - -## Critical Formatting Rules - -Subagents MUST follow strict formatting or they will fail validation and not load: - -| Rule | Requirement | Consequence | -|------|-------------|-------------| -| **Single-line description** | `description` must be one line, no `\n` | Validation failure | -| **No literal `\n`** | Use actual newlines in body, not `\n` escapes | Validation failure | -| **Valid colors only** | If specified: red, blue, green, yellow, purple, orange, pink, cyan | Agent won't load | -| **Valid models only** | `sonnet`, `opus`, `haiku`, or `inherit` | Validation failure | -| **Name format** | Lowercase letters, numbers, hyphens only | Validation failure | - -### Examples of WRONG vs RIGHT - -```yaml -# WRONG - Multi-line YAML syntax -description: | - Expert code reviewer. - Use after code changes. - -# WRONG - Actual newlines in value -description: Expert code reviewer. - Use after code changes. - -# RIGHT - ONE continuous line -description: Expert code reviewer. Use proactively after code changes. -``` - -```yaml -# WRONG - Literal \n in body ---- -name: test-runner -description: Run tests ---- - -You are a test runner.\nWhen invoked:\n1. Run tests\n2. Report results - -# RIGHT - Actual newlines ---- -name: test-runner -description: Run tests ---- - -You are a test runner. -When invoked: -1. Run tests -2. Report results -``` - -## Workflow - -### 1. Understand Requirements - -When this skill triggers, the user has described what kind of agent they want. First, extract: - -- **Core purpose**: What should this agent do? -- **Trigger conditions**: When should Claude delegate to this agent? -- **Key capabilities**: What specific tasks will it handle? -- **Existing context**: Any relevant skills, docs, or project patterns? - -### 2. Use AskUserQuestion for Details - -Ask clarifying questions using the AskUserQuestion tool. Confirm: - -- **Identifier**: What should the agent be named? (lowercase-with-hyphens) -- **Proactive usage**: Should this agent be used proactively or only on explicit request? -- **Model**: Default to `inherit`. Only suggest `haiku` for simple, fast tasks. ALWAYS confirm before using non-inherit models. -- **Color**: Auto-select from valid options (red, blue, green, yellow, purple, orange, pink, cyan) OR let user choose. - -### 3. Identify Helpful Context - -Search the workspace for: - -**Relevant skills**: Check `.claude/skills/` and project skills that would help the agent. -```bash -ls .claude/skills/ -``` - -**Relevant documentation**: Look for references files, CLAUDE.md, API docs, etc. -```bash -find . -name "*.md" -type f | head -20 -``` - -### 4. Design the System Prompt - -Using the agent creation architect framework (see `references/agent-creation-prompt.md`): - -1. **Extract Core Intent** - Fundamental purpose and success criteria -2. **Design Expert Persona** - Compelling identity with domain knowledge -3. **Architect Instructions** - Behavioral boundaries, methodologies, edge cases -4. **Optimize for Performance** - Decision frameworks, quality control -5. **Create whenToUse Examples** - Concrete examples showing delegation - -The system prompt should be in second person ("You are...", "You will..."). - -### 5. Write the Agent File - -Write the agent file to `.claude/agents/.md`: - -```yaml ---- -name: -description: -model: inherit ---- - - -``` - -**Default settings:** -- `model`: Always `inherit` unless user confirms otherwise -- `tools`: Omit to allow all tools (user preference: never restrict) -- `skills`: Include if specific skills would help the agent - -### 6. Validate Before Completing - -Run the validation script: -```bash -python .claude/skills/sub-agent-creator/scripts/validate_agent.py .claude/agents/.md -``` - -Only proceed if validation passes. Fix any errors and re-validate. - -## Agent File Template - -```yaml ---- -name: agent-identifier -description: Brief single-line description starting with what it does. Use proactively when [trigger condition]. -model: inherit -skills: - - relevant-skill-1 - - relevant-skill-2 ---- - -You are an expert [domain] specialist. - -When invoked: -1. [First step] -2. [Second step] -3. [Continue as needed] - -Your approach: -- [Guideline 1] -- [Guideline 2] - -For each [task], provide: -- [Output format 1] -- [Output format 2] - -Focus on [core principle]. -``` - -## Description Best Practices - -The `description` field is Claude's primary signal for when to delegate. Include: - -- **What the agent does**: "Expert code reviewer specializing in..." -- **When to use**: "Use proactively after writing code" or "Use when analyzing..." -- **Triggers**: Specific situations that should trigger delegation - -Examples: -```yaml -description: Test execution specialist. Use proactively after writing tests or modifying test files. -description: Database query analyst. Use when needing to analyze data or generate reports from BigQuery. -description: Code archaeology expert. Use when exploring legacy codebases or understanding unfamiliar code. -``` - -## Resources - -- **scripts/validate_agent.py** - Validates agent files for formatting errors -- **references/sub-agents-docs.md** - Complete Claude Code subagents documentation -- **references/agent-creation-prompt.md** - Agent creation architect system prompt framework - -After creating an agent, verify it appears in `/agents` command output. diff --git a/.claude/skills/sub-agent-creator/references/agent-creation-prompt.md b/.claude/skills/sub-agent-creator/references/agent-creation-prompt.md deleted file mode 100644 index b53c453..0000000 --- a/.claude/skills/sub-agent-creator/references/agent-creation-prompt.md +++ /dev/null @@ -1,80 +0,0 @@ -# Agent Creation Architect System Prompt - -Source: https://github.com/Piebald-AI/claude-code-system-prompts/blob/main/system-prompts/agent-prompt-agent-creation-architect.md - -You are an elite AI agent architect specializing in crafting high-performance agent configurations. Your expertise lies in translating user requirements into precisely-tuned agent specifications that maximize effectiveness and reliability. - -**Important Context**: You may have access to project-specific instructions from CLAUDE.md files and other context that may include coding standards, project structure, and custom requirements. Consider this context when creating agents to ensure they align with the project's established patterns and practices. - -When a user describes what they want an agent to do, you will: - -1. **Extract Core Intent**: Identify the fundamental purpose, key responsibilities, and success criteria for the agent. Look for both explicit requirements and implicit needs. Consider any project-specific context from CLAUDE.md files. For agents that are meant to review code, you should assume that the user is asking to review recently written code and not the whole codebase, unless the user has explicitly instructed you otherwise. - -2. **Design Expert Persona**: Create a compelling expert identity that embodies deep domain knowledge relevant to the task. The persona should inspire confidence and guide the agent's decision-making approach. - -3. **Architect Comprehensive Instructions**: Develop a system prompt that: - - Establishes clear behavioral boundaries and operational parameters - - Provides specific methodologies and best practices for task execution - - Anticipates edge cases and provides guidance for handling them - - Incorporates any specific requirements or preferences mentioned by the user - - Defines output format expectations when relevant - - Aligns with project-specific coding standards and patterns from CLAUDE.md - -4. **Optimize for Performance**: Include: - - Decision-making frameworks appropriate to the domain - - Quality control mechanisms and self-verification steps - - Efficient workflow patterns - - Clear escalation or fallback strategies - -5. **Create Identifier**: Design a concise, descriptive identifier that: - - Uses lowercase letters, numbers, and hyphens only - - Is typically 2-4 words joined by hyphens - - Clearly indicates the agent's primary function - - Is memorable and easy to type - - Avoids generic terms like "helper" or "assistant" - -6. **Example agent descriptions**: In the 'whenToUse' field of the JSON object, you should include examples of when this agent should be used. Examples should be of the form: - -``` -Context: The user is creating a test-runner agent that should be called after a logical chunk of code is written. -user: "Please write a function that checks if a number is prime" -assistant: "Here is the relevant function: " - -Since a significant piece of code was written, use the ${TASK_TOOL_NAME} tool to launch the test-runner agent to run the tests. -assistant: "Now let me use the test-runner agent to run the tests" -``` - -``` -Context: User is creating an agent to respond to the word "hello" with a friendly joke. -user: "Hello" -assistant: "I'm going to use the ${TASK_TOOL_NAME} tool to launch the greeting-responder agent to respond with a friendly joke" - -Since the user is greeting, use the greeting-responder agent to respond with a friendly joke. -``` - -If the user mentioned or implied that the agent should be used proactively, you should include examples of this. - -**NOTE**: Ensure that in the examples, you are making the assistant use the Agent tool and not simply respond directly to the task. - -## Output Format - -Your output must be a valid JSON object with exactly these fields: - -```json -{ -"identifier": "A unique, descriptive identifier using lowercase letters, numbers, and hyphens (e.g., 'test-runner', 'api-docs-writer', 'code-formatter')", -"whenToUse": "A precise, actionable description starting with 'Use this agent when...' that clearly defines the triggering conditions and use cases. Ensure you include examples as described above.", -"systemPrompt": "The complete system prompt that will govern the agent's behavior, written in second person ('You are...', 'You will...') and structured for maximum clarity and effectiveness" -} -``` - -## Key Principles - -- Be specific rather than generic - avoid vague instructions -- Include concrete examples when they would clarify behavior -- Balance comprehensiveness with clarity - every instruction should add value -- Ensure the agent has enough context to handle variations of the core task -- Make the agent proactive in seeking clarification when needed -- Build in quality assurance and self-correction mechanisms - -Remember: The agents you create should be autonomous experts capable of handling their designated tasks with minimal additional guidance. Your system prompts are their complete operational manual. diff --git a/.claude/skills/sub-agent-creator/references/sub-agents-docs.md b/.claude/skills/sub-agent-creator/references/sub-agents-docs.md deleted file mode 100644 index 99c0ac8..0000000 --- a/.claude/skills/sub-agent-creator/references/sub-agents-docs.md +++ /dev/null @@ -1,295 +0,0 @@ -# Subagents Documentation - -Source: https://code.claude.com/docs/en/sub-agents - -## Overview - -Subagents are specialized AI assistants that handle specific types of tasks. Each subagent runs in its own context window with a custom system prompt, specific tool access, and independent permissions. When Claude encounters a task that matches a subagent's description, it delegates to that subagent, which works independently and returns results. - -Subagents help you: -- Preserve context by keeping exploration and implementation out of your main conversation -- Enforce constraints by limiting which tools a subagent can use -- Reuse configurations across projects with user-level subagents -- Specialize behavior with focused system prompts for specific domains -- Control costs by routing tasks to faster, cheaper models like Haiku - -Claude uses each subagent's description to decide when to delegate tasks. When you create a subagent, write a clear description so Claude knows when to use it. - -## Built-in Subagents - -### Explore -A fast, read-only agent optimized for searching and analyzing codebases. -- **Model**: Haiku (fast, low-latency) -- **Tools**: Read-only tools (denied access to Write and Edit tools) -- **Purpose**: File discovery, code search, codebase exploration - -### Plan -A research agent used during plan mode to gather context before presenting a plan. -- **Model**: Inherits from main conversation -- **Tools**: Read-only tools (denied access to Write and Edit tools) -- **Purpose**: Codebase research for planning - -### General-purpose -A capable agent for complex, multi-step tasks that require both exploration and action. -- **Model**: Inherits from main conversation -- **Tools**: All tools -- **Purpose**: Complex research, multi-step operations, code modifications - -## Subagent File Structure - -Subagents are defined in Markdown files with YAML frontmatter. Store them in `.claude/agents/` for project-level agents. - -``` ---- -name: code-reviewer -description: Reviews code for quality and best practices -tools: Read, Glob, Grep -model: sonnet ---- - -You are a code reviewer. When invoked, analyze the code and provide -specific, actionable feedback on quality, security, and best practices. -``` - -## Frontmatter Fields - -| Field | Required | Description | -| --- | --- | --- | -| `name` | Yes | Unique identifier using lowercase letters and hyphens | -| `description` | Yes | When Claude should delegate to this subagent | -| `tools` | No | Tools the subagent can use. Inherits all tools if omitted | -| `disallowedTools` | No | Tools to deny, removed from inherited or specified list | -| `model` | No | Model to use: `sonnet`, `opus`, `haiku`, or `inherit`. Defaults to `inherit` | -| `permissionMode` | No | Permission mode: `default`, `acceptEdits`, `dontAsk`, `bypassPermissions`, or `plan` | -| `skills` | No | Skills to load into the subagent's context at startup | -| `hooks` | No | Lifecycle hooks scoped to this subagent | - -## CRITICAL: Formatting Requirements - -These formatting rules MUST be followed strictly or the subagent will fail to load: - -### 1. Description - MUST be single line -The `description` field MUST be a single line of text. Multi-line descriptions will cause validation failure. - -```yaml -# CORRECT - Single line -description: Expert code reviewer. Use proactively after code changes. - -# INCORRECT - Multi-line -description: | - Expert code reviewer. - Use proactively after code changes. -``` - -### 2. Prompt/Body - No literal \n characters -The system prompt (markdown body) must NOT contain literal `\n` escape sequences. Use actual newlines instead. - -```yaml -# CORRECT - Actual newlines ---- -name: test-runner -description: Run tests after code changes ---- - -You are a test runner. When invoked: -1. Run the test suite -2. Report failures - -# INCORRECT - Literal \n ---- -name: test-runner -description: Run tests after code changes ---- - -You are a test runner. When invoked:\n1. Run the test suite\n2. Report failures -``` - -### 3. Color field (if using) -If specifying a color for the subagent, ONLY these values are allowed: -- red -- blue -- green -- yellow -- purple -- orange -- pink -- cyan - -Any other color value will cause the subagent to fail validation and not be loaded. - -### 4. Model field -Allowed values: `sonnet`, `opus`, `haiku`, or `inherit`. Default is `inherit` if not specified. - -### 5. Tools field -Tools should be comma-separated on a single line: -```yaml -tools: Read, Write, Edit, Bash, Grep, Glob -``` - -## Tool Restrictions - -### Available Tools -Subagents can use any of Claude Code's internal tools. To restrict tools, use the `tools` field (allowlist) or `disallowedTools` field (denylist): - -```yaml ---- -name: safe-researcher -description: Research agent with restricted capabilities -tools: Read, Grep, Glob, Bash -disallowedTools: Write, Edit ---- -``` - -### Default Behavior -- If `tools` is omitted, subagent inherits ALL tools from the main conversation -- This is the recommended approach for most custom subagents - -## Model Selection - -- `inherit`: Use the same model as the main conversation (default) -- `sonnet`: Capable model for complex tasks -- `opus`: Most capable model for difficult reasoning -- `haiku`: Fast, low-latency model for simple tasks - -For most subagents, omit the `model` field to use `inherit`. - -## Permission Modes - -| Mode | Behavior | -| --- | --- | -| `default` | Standard permission checking with prompts | -| `acceptEdits` | Auto-accept file edits | -| `dontAsk` | Auto-deny permission prompts (explicitly allowed tools still work) | -| `bypassPermissions` | Skip all permission checks | -| `plan` | Plan mode (read-only exploration) | - -## Skills Field - -Use the `skills` field to inject skill content into a subagent's context at startup: - -```yaml ---- -name: api-developer -description: Implement API endpoints following team conventions -skills: - - api-conventions - - error-handling-patterns ---- -``` - -The full content of each skill is injected into the subagent's context. Subagents don't inherit skills from the parent conversation; you must list them explicitly. - -## Hooks - -Subagents can define hooks that run during the subagent's lifecycle: - -| Event | Matcher input | When it fires | -| --- | --- | --- | -| `PreToolUse` | Tool name | Before the subagent uses a tool | -| `PostToolUse` | Tool name | After the subagent uses a tool | -| `Stop` | (none) | When the subagent finishes | - -```yaml ---- -name: code-reviewer -description: Review code changes with automatic linting -hooks: - PreToolUse: - - matcher: "Bash" - hooks: - - type: command - command: "./scripts/validate-command.sh $TOOL_INPUT" - PostToolUse: - - matcher: "Edit|Write" - hooks: - - type: command - command: "./scripts/run-linter.sh" ---- -``` - -## Automatic Delegation - -Claude automatically delegates tasks based on: -- The task description in your request -- The `description` field in subagent configurations -- Current context - -To encourage proactive delegation, include phrases like "use proactively" in your subagent's `description` field. - -## Subagent Locations - -| Location | Scope | Priority | -| --- | --- | --- | -| `.claude/agents/` | Current project | 2 | -| `~/.claude/agents/` | All your projects | 3 | -| Plugin's `agents/` directory | Where plugin is enabled | 4 (lowest) | - -## Example Subagents - -### Code Reviewer -```yaml ---- -name: code-reviewer -description: Expert code review specialist. Proactively reviews code for quality, security, and maintainability. Use immediately after writing or modifying code. -tools: Read, Grep, Glob, Bash -model: inherit ---- - -You are a senior code reviewer ensuring high standards of code quality and security. - -When invoked: -1. Run git diff to see recent changes -2. Focus on modified files -3. Begin review immediately - -Review checklist: -- Code is clear and readable -- Functions and variables are well-named -- No duplicated code -- Proper error handling -- No exposed secrets or API keys -- Input validation implemented -- Good test coverage -- Performance considerations addressed - -Provide feedback organized by priority: -- Critical issues (must fix) -- Warnings (should fix) -- Suggestions (consider improving) - -Include specific examples of how to fix issues. -``` - -### Debugger -```yaml ---- -name: debugger -description: Debugging specialist for errors, test failures, and unexpected behavior. Use proactively when encountering any issues. -tools: Read, Edit, Bash, Grep, Glob ---- - -You are an expert debugger specializing in root cause analysis. - -When invoked: -1. Capture error message and stack trace -2. Identify reproduction steps -3. Isolate the failure location -4. Implement minimal fix -5. Verify solution works - -Debugging process: -- Analyze error messages and logs -- Check recent code changes -- Form and test hypotheses -- Add strategic debug logging -- Inspect variable states - -For each issue, provide: -- Root cause explanation -- Evidence supporting the diagnosis -- Specific code fix -- Testing approach -- Prevention recommendations - -Focus on fixing the underlying issue, not the symptoms. -``` diff --git a/.claude/skills/sub-agent-creator/scripts/validate_agent.py b/.claude/skills/sub-agent-creator/scripts/validate_agent.py deleted file mode 100644 index f099f26..0000000 --- a/.claude/skills/sub-agent-creator/scripts/validate_agent.py +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env python3 -""" -Validate a Claude Code subagent markdown file. - -This script checks for common formatting errors that cause subagents -to fail validation and not be loaded by Claude Code. - -Run: python validate_agent.py -""" - -import sys -import re -from pathlib import Path - - -# Allowed color values -VALID_COLORS = {"red", "blue", "green", "yellow", "purple", "orange", "pink", "cyan"} - -# Allowed model values -VALID_MODELS = {"sonnet", "opus", "haiku", "inherit"} - -# Required frontmatter fields -REQUIRED_FIELDS = {"name", "description"} - - -def parse_frontmatter(content): - """Parse YAML frontmatter from markdown content.""" - if not content.startswith("---"): - return None, "File does not start with YAML frontmatter (---)" - - end_delimiter = content.find("\n---", 4) - if end_delimiter == -1: - return None, "No closing frontmatter delimiter (---) found" - - frontmatter_text = content[4:end_delimiter] - body = content[end_delimiter + 4:] # Skip past the closing --- - - # Parse simple YAML key-value pairs - frontmatter = {} - for line in frontmatter_text.strip().split("\n"): - if ":" in line and not line.strip().startswith("#"): - key, value = line.split(":", 1) - key = key.strip() - value = value.strip() - # Remove quotes if present - if value.startswith('"') and value.endswith('"'): - value = value[1:-1] - elif value.startswith("'") and value.endswith("'"): - value = value[1:-1] - frontmatter[key] = value - - return frontmatter, body - - -def check_literal_newlines(text, context=""): - """Check for literal \n escape sequences in the text.""" - errors = [] - # Look for literal \n not followed by another backslash (which would be \\n) - # We want to catch \n that would appear as actual escape sequences - pattern = r'(? multi-line YAML syntax.") - - # Check for literal \n in frontmatter values - for key, value in frontmatter.items(): - if isinstance(value, str): - if '\\n' in value and key not in ["description"]: # description already checked above - if not value.strip().endswith("\\n") or value.count('\\n') > 1: - errors.append(f"Field '{key}' contains literal \\n. Use actual line breaks") - - # Check color if present - if "color" in frontmatter: - color = frontmatter["color"].lower() - if color not in VALID_COLORS: - errors.append(f"Invalid color '{frontmatter['color']}'. Must be one of: {', '.join(sorted(VALID_COLORS))}") - - # Check model if present - if "model" in frontmatter: - model = frontmatter["model"].lower() - if model not in VALID_MODELS: - errors.append(f"Invalid model '{frontmatter['model']}'. Must be one of: {', '.join(sorted(VALID_MODELS))}") - - # Check for common multi-line frontmatter issues - frontmatter_section = content.split("\n---\n", 1)[0] - if "description:" in frontmatter_section: - desc_match = re.search(r'description:\s*[|>]', frontmatter_section) - if desc_match: - errors.append("Description field must be ONE continuous line with NO line breaks. Do not use | or > multi-line YAML syntax.") - - # Check body for literal \n - if body: - body_errors = check_literal_newlines(body) - errors.extend(body_errors) - - # Check that tools is comma-separated if present - if "tools" in frontmatter: - tools = frontmatter["tools"] - if "\n" in tools: - errors.append("Tools field must be on a single line, comma-separated") - - # Check for whenToUse field (should be in body, not frontmatter for agent creation architect) - if "whenToUse" in frontmatter or "whentouse" in frontmatter: - warnings.append("whenToUse in frontmatter may not be standard for subagent files") - - return frontmatter, errors, warnings - - -def main(): - if len(sys.argv) < 2: - print("Usage: python validate_agent.py ") - print("\nValidates a Claude Code subagent markdown file for:") - print(" - Required frontmatter fields (name, description)") - print(" - Single-line description (no multi-line)") - print(" - No literal \\n escape sequences") - print(" - Valid color values (if specified)") - print(" - Valid model values (if specified)") - sys.exit(1) - - filepath = sys.argv[1] - - result = validate_agent_file(filepath) - - if len(result) == 2: - # Error occurred - frontmatter, errors = result - print(f"[FAIL] Validation FAILED: {filepath}") - for error in errors: - print(f" - {error}") - sys.exit(1) - else: - frontmatter, errors, warnings = result - - if errors: - print(f"[FAIL] Validation FAILED: {filepath}") - for error in errors: - print(f" - {error}") - if warnings: - print("\n[WARN] Warnings:") - for warning in warnings: - print(f" - {warning}") - sys.exit(1) - else: - print(f"[PASS] Validation PASSED: {filepath}") - if frontmatter: - print(f"\n name: {frontmatter.get('name', 'N/A')}") - desc = frontmatter.get('description', 'N/A') - print(f" description: {desc[:60]}{'...' if len(desc) > 60 else ''}") - if warnings: - print("\n[WARN] Warnings:") - for warning in warnings: - print(f" - {warning}") - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/.gitignore b/.gitignore index 1f78bc3..dd03930 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -/ai_reference -/docs-wiki +flashforge-dashboard/node_modules/ +*.log diff --git a/README.md b/README.md index 83f249c..ff06be7 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,45 @@ -# FlashForge API Documentation +# FlashForge Home Assistant Add-on Repository -Unofficial, community-driven documentation for FlashForge 3D printer networking protocols and APIs. Covers the Adventurer 3, Adventurer 4, Adventurer 5M, and AD5X printer families. +Repository add-on per Home Assistant. +Contiene l’add-on **FlashForge Dashboard** pronto per essere aggiunto nello store di Home Assistant. -All documentation lives in the **[Wiki](../../wiki)**. +## Installazione ---- +1. In Home Assistant vai su **Impostazioni → Add-on → Store** +2. Clicca **⋮ → Repositories** +3. Aggiungi questo URL: -## Supported Printer Families +```text +https://github.com/MikManenti/flashforge-api-docs +``` -| Family | Primary API | Auth | Wiki Page | -|--------|-------------|------|-----------| -| Adventurer 5M / 5M Pro | HTTP REST (8898) + TCP (8899) | CheckCode | [Adventurer 5M Series](../../wiki/Adventurer-5M-Series) | -| AD5X | HTTP REST (8898) + TCP (8899) + IFS | CheckCode | [AD5X](../../wiki/AD5X) | -| Adventurer 4 Pro / Lite | TCP (8899) | None | [Adventurer 4 Series](../../wiki/Adventurer-4-Series) | -| Adventurer 3 Series | TCP (8899) | None | [Adventurer 3 Series](../../wiki/Adventurer-3-Series) | +4. Cerca e installa **FlashForge Dashboard** -## Documentation +## Add-on incluso -### Core Protocols +### FlashForge Dashboard +Dashboard locale per stampanti FlashForge AD5M / AD5X / 5M Pro con: +- camera live +- stato stampa +- temperature +- controlli pausa/riprendi/stop +- lista file e upload GCode -- **[HTTP REST API](../../wiki/HTTP-REST-API)** — JSON-based control interface on port 8898 (5M / AD5X) -- **[TCP Protocol](../../wiki/TCP-Protocol)** — Text-based G/M-code interface on port 8899 (all models) -- **[Discovery Protocol](../../wiki/Discovery-Protocol)** — UDP auto-discovery for finding printers on the network -- **[Authentication](../../wiki/Authentication)** — CheckCode auth for HTTP API, open access for TCP +## Struttura repository -### References +```text +. +├── repository.yaml +└── flashforge-dashboard/ + ├── config.yaml + ├── build.yaml + ├── Dockerfile + ├── run.sh + ├── server.js + ├── package.json + └── frontend/public/ +``` -- **[G-Code Reference](../../wiki/G%E2%80%90Code-Reference)** — Supported G-code commands via TCP -- **[M-Code Reference](../../wiki/M-Code-Reference)** — Standard and proprietary FlashForge M-codes -- **[State Machines](../../wiki/State-Machines)** — Unified state model across modern and legacy firmware -- **[Capability Matrix](../../wiki/Capability-Matrix)** — Feature support matrix across all printer models -- **[Error Codes](../../wiki/Error-Codes)** — HTTP, TCP, and IFS error codes with recovery strategies +## Note -### Printer-Specific - -- **[Adventurer 5M Series](../../wiki/Adventurer-5M-Series)** — HTTP/TCP architecture, status polling, file operations - - **[5M Pro Features](../../wiki/Adventurer-5M-Pro-Features)** — Camera, air filtration, TVOC monitoring -- **[AD5X](../../wiki/AD5X)** — Material station commands, manual control, extended endpoints - - **[IFS Material Station](../../wiki/AD5X-IFS-Material-Station)** — Multi-material slot management, load/unload, color mapping -- **[Adventurer 4 Series](../../wiki/Adventurer-4-Series)** — TCP-only, 33 commands, Pro/Lite variant comparison -- **[Adventurer 3 Series](../../wiki/Adventurer-3-Series)** — Legacy TCP protocol, 37 commands, 4 variant comparison - -### Advanced - -- **[AD5X Root Access](../../wiki/AD5X-Root-Access)** — Root SSH/ADB via USB firmware update mechanism -- **[AD5X Maintenance Console](../../wiki/AD5X-Maintenance-Console)** — Hidden touchscreen debug/calibration UI -- **[5M Maintenance Console](../../wiki/Adventurer-5M-Maintenance-Console)** — Hidden maintenance UI for 5M/5M Pro -- **[AD5X IFS Serial Protocol](../../wiki/AD5X-IFS-Serial-Protocol)** — Raw UART protocol for the Intelligent Filament Station -- **[AD5X Platform Notes](../../wiki/AD5X-Platform-Notes)** — Ingenic X2600 MIPS32 SoC, hardware, kernel, filesystem - -## Endpoint YAML Files - -Machine-readable endpoint specifications are available in the [`endpoints/`](endpoints/) directory: - -| File | Description | -|------|-------------| -| `endpoints_5m_3.2.7.yaml` | Adventurer 5M / 5M Pro HTTP endpoints (firmware 3.2.7) | -| `endpoints_ad5x_1.1.7.yaml` | AD5X HTTP endpoints (firmware 1.1.7) | -| `endpoints_ad5x_1.2.1.yaml` | AD5X HTTP endpoints (firmware 1.2.1) | -| `networkserver_commands_adventurer3.yaml` | Adventurer 3 TCP commands | -| `networkserver_commands_adventurer4.yaml` | Adventurer 4 TCP commands | - -## Methodology - -This documentation is compiled from: - -- Firmware analysis and filesystem inspection -- Network traffic capture and protocol analysis -- Community testing across multiple printer models and firmware versions - -## Contributing - -Contributions are welcome via pull requests. When submitting: - -- Specify the printer model and firmware version tested -- Include methodology (traffic capture, binary analysis, live testing) -- Reference specific protocol details or packet captures where possible - ---- - -*This is an unofficial community project and is not affiliated with FlashForge.* +Progetto non ufficiale, non affiliato con FlashForge o Home Assistant. diff --git a/endpoints/endpoints_5m_3.2.7.yaml b/endpoints/endpoints_5m_3.2.7.yaml deleted file mode 100644 index 8e4bc4f..0000000 --- a/endpoints/endpoints_5m_3.2.7.yaml +++ /dev/null @@ -1,821 +0,0 @@ -paths: - /checkCode: - post: - description: >- - Validate serialNumber + checkCode for LAN access. Returns code/message. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - - checkCode - properties: - serialNumber: - type: string - checkCode: - type: string - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/CodeMessageResponse' - /detail: - post: - description: >- - Gets info on the current status and config of the printer. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - - checkCode - properties: - serialNumber: - type: string - checkCode: - type: string - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/DetailResponse' - /product: - post: - description: >- - Returns product capability/control state flags. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - - checkCode - properties: - serialNumber: - type: string - checkCode: - type: string - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/ProductResponse' - /control: - post: - description: >- - Send a command to the printer. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - - checkCode - - payload - properties: - serialNumber: - type: string - checkCode: - type: string - payload: - oneOf: - - $ref: "#/components/schemas/LightControlCmd" - - $ref: "#/components/schemas/CirculateCtlCmd" - - $ref: "#/components/schemas/TemperatureCtlCmd" - - $ref: "#/components/schemas/PrinterCtlCmd" - - $ref: "#/components/schemas/JobCtlCmd" - - $ref: "#/components/schemas/DelayCloseCmd" - - $ref: "#/components/schemas/ReNameCmd" - - $ref: "#/components/schemas/CalibrationCmd" - - $ref: "#/components/schemas/StreamCtrlCmd" - - $ref: "#/components/schemas/UserProfileCmd" - - $ref: "#/components/schemas/StateCtrlCmd" - - $ref: "#/components/schemas/DeviceUnregisterCmd" - - $ref: "#/components/schemas/DeviceUpdateDetailCmd" - - $ref: "#/components/schemas/NewJobCmd" - - $ref: "#/components/schemas/NewLocalJobCmd" - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/CodeMessageResponse' - /gcodeList: - post: - description: >- - Returns a list of printable files from /data (extensions: .g, .gx, .gcode, .3mf). - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - - checkCode - properties: - serialNumber: - type: string - checkCode: - type: string - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GcodeListResponse' - /gcodeThumb: - post: - description: >- - Returns a base64 thumbnail for a given fileName. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - - checkCode - - fileName - properties: - serialNumber: - type: string - checkCode: - type: string - fileName: - type: string - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GcodeThumbResponse' - /printGcode: - post: - description: >- - Start printing a file from /data. Returns code/message. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - - checkCode - - fileName - - levelingBeforePrint - properties: - serialNumber: - type: string - checkCode: - type: string - fileName: - type: string - levelingBeforePrint: - type: boolean - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/CodeMessageResponse' - /getThum: - get: - description: >- - Returns current print thumbnail as image/bmp. On error returns JSON code/message. - responses: - '200': - content: - image/bmp: - schema: - type: string - format: binary - application/json: - schema: - $ref: '#/components/schemas/CodeMessageResponse' - /notifyWanBind: - post: - description: >- - Notify WAN bind. Validates serialNumber and triggers a bind event on success. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - properties: - serialNumber: - type: string - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/CodeMessageResponse' - /uploadGcode: - post: - description: >- - Upload a gcode/3mf file to local storage via multipart/form-data. - Requires headers for serialNumber/checkCode/fileSize/printNow/levelingBeforePrint. - parameters: - - in: header - name: serialNumber - required: true - schema: - type: string - - in: header - name: checkCode - required: true - schema: - type: string - - in: header - name: fileSize - required: true - schema: - type: integer - description: File size in bytes - - in: header - name: printNow - required: true - schema: - type: string - description: String boolean, typically "0" or "1" - - in: header - name: levelingBeforePrint - required: true - schema: - type: string - description: String boolean, typically "0" or "1" - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - required: - - gcodeFile - properties: - gcodeFile: - type: string - format: binary - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/CodeMessageResponse' -components: - schemas: - DetailResponse: - type: object - properties: - code: - type: integer - message: - type: string - detail: - $ref: '#/components/schemas/Details' - IsOpenEnum: - type: string - enum: - - open - - close - CodeMessageResponse: - type: object - properties: - code: - type: integer - message: - type: string - ProductResponse: - type: object - properties: - code: - type: integer - message: - type: string - product: - type: object - properties: - nozzleTempCtrlState: - type: integer - chamberTempCtrlState: - type: integer - platformTempCtrlState: - type: integer - lightCtrlState: - type: integer - internalFanCtrlState: - type: integer - externalFanCtrlState: - type: integer - GcodeListResponse: - type: object - properties: - code: - type: integer - message: - type: string - gcodeList: - type: array - items: - type: string - GcodeThumbResponse: - type: object - properties: - code: - type: integer - message: - type: string - imageData: - type: string - description: Base64-encoded thumbnail image - Details: - type: object - description: Verified in 3.2.7 (request_detail). - properties: - autoShutdown: - $ref: "#/components/schemas/IsOpenEnum" - autoShutdownTime: - type: integer - cameraStreamUrl: - type: string - description: Empty if camera disabled; otherwise http://:8080/?action=stream. - chamberFanSpeed: - type: integer - chamberTargetTemp: - type: number - chamberTemp: - type: number - coolingFanSpeed: - type: integer - cumulativeFilament: - type: number - cumulativePrintTime: - type: integer - currentPrintSpeed: - type: integer - doorStatus: - $ref: "#/components/schemas/IsOpenEnum" - errorCode: - type: string - estimatedLeftLen: - type: number - estimatedLeftWeight: - type: number - estimatedRightLen: - type: number - estimatedRightWeight: - type: number - estimatedTime: - type: number - externalFanStatus: - $ref: "#/components/schemas/IsOpenEnum" - fillAmount: - type: integer - firmwareVersion: - type: string - flashRegisterCode: - type: string - internalFanStatus: - $ref: "#/components/schemas/IsOpenEnum" - ipAddr: - type: string - leftFilamentType: - type: string - leftTargetTemp: - type: number - leftTemp: - type: number - lightStatus: - $ref: "#/components/schemas/IsOpenEnum" - location: - type: string - macAddr: - type: string - measure: - type: string - name: - type: string - nozzleCnt: - type: integer - nozzleModel: - type: string - nozzleStyle: - type: integer - pid: - type: integer - platTargetTemp: - type: number - platTemp: - type: number - polarRegisterCode: - type: string - printDuration: - type: integer - printFileName: - type: string - printFileThumbUrl: - type: string - description: Empty if no thumb; otherwise http://:8898/getThum. Base file dir is /data/. - printLayer: - type: integer - printProgress: - type: number - printSpeedAdjust: - type: number - remainingDiskSpace: - type: number - rightFilamentType: - type: string - rightTargetTemp: - type: number - rightTemp: - type: number - status: - type: string - targetPrintLayer: - type: integer - tvoc: - type: integer - zAxisCompensation: - type: number - JobInfo: - type: array - description: | - Array of job tuples. Structure: `[[deviceSN, jobID, filepath], ...]`. - Firmware iterates this array looking for a `deviceSN` (index 0) match with the local printer. - items: - type: array - items: - type: string - NewJobCmd: - type: object - description: Start a cloud/multi-device job. - required: - - cmd - - args - properties: - cmd: - type: string - enum: [newJob_cmd] - args: - type: object - required: - - jobInfo - properties: - jobRouter: - type: boolean - description: | - **Presence Flag**: The value (true/false) is ignored. If this key is *present* in the JSON, - the firmware extracts the filepath from `jobInfo` tuple index 2. - If *absent*, it uses the root `filepath` property. - jobInfo: - type: array - items: - - $ref: "#/components/schemas/JobInfo" - filepath: - type: string - description: only read if jobRouter is not declared - filename: - type: string - thumbPath: - type: string - fileMd5: - type: string - printNow: - type: boolean - leveling: - type: boolean - LightControlCmd: - type: object - description: set light states - required: - - cmd - - args - properties: - cmd: - type: string - enum: [lightControl_cmd] - args: - type: object - required: - - status - properties: - status: - type: string - CirculateCtlCmd: - type: object - description: set fan states - required: - - cmd - - args - properties: - cmd: - type: string - enum: [circulateCtl_cmd] - args: - type: object - required: - - internal - - external - properties: - internal: - type: string - external: - type: string - TemperatureCtlCmd: - type: object - description: Set nozzle, bed, and chamber temperatures - required: - - cmd - - args - properties: - cmd: - type: string - enum: [temperatureCtl_cmd] - args: - type: object - properties: - platform: - type: integer - default: -200 - description: Bed temperature (°C). -100 = off (0), -200 = no change. - rightNozzle: - type: integer - default: -200 - description: Right/main nozzle temperature (°C). -100 = off (0), -200 = no change. - leftNozzle: - type: integer - default: -200 - description: Left nozzle temperature (°C). -100 = off (0), -200 = no change. - chamber: - type: integer - default: -200 - description: Chamber temperature (°C). -100 = off (0), -200 = no change. - PrinterCtlCmd: - type: object - description: | - Set speeds, fans, and z-axis compensation. - **Partial Update Behavior:** This command supports partial updates. Fields omitted from the payload - are ignored by the firmware (using internal sentinel values, e.g., -200). - **Important:** Do NOT send default values (like 0) for fields you do not intend to change, - as this will overwrite the user's current settings. - required: - - cmd - - args - properties: - cmd: - type: string - enum: [printerCtl_cmd] - args: - type: object - properties: - zAxisCompensation: - type: number - default: -200.0 - description: | - Z-offset in mm. Maps to `SET_GCODE_OFFSET Z= MOVE=1`. - Range: -5.0 to +5.0. Values outside this range are ignored. - Sentinel: -200.0 (ignore). - speed: - type: integer - default: -200 - description: | - Print speed percentage. Maps to `M220 S`. - Clamped range: 50-150. - Sentinel: -200 (ignore). - chamberFan: - type: integer - default: -200 - description: Chamber fan speed (0-255, 0=off). Sentinel -200 (ignore). - coolingFan: - type: integer - default: -200 - description: Cooling fan speed (0-255, 0=off). Sentinel -200 (ignore). - JobCtlCmd: - type: object - description: Control print jobs (pause, resume, cancel) - required: - - cmd - - args - properties: - cmd: - type: string - enum: [jobCtl_cmd] - args: - type: object - required: - - jobID - - action - properties: - jobID: - type: string - description: Job identifier - action: - type: string - description: Control action - enum: [pause, continue, resume, cancel, stop] - DelayCloseCmd: - type: object - description: configure automatic power-off - required: - - cmd - - args - properties: - cmd: - type: string - enum: [delayClose_cmd] - args: - type: object - required: - - automaticShutdown - - shutdownAfterTime - properties: - automaticShutdown: - type: string - shutdownAfterTime: - type: integer - ReNameCmd: - type: object - description: give your printer a nickname - required: - - cmd - - args - properties: - cmd: - type: string - enum: [reName_cmd] - args: - type: object - required: - - name - properties: - name: - type: string - CalibrationCmd: - type: object - description: configure leveling and vibration compensation - required: - - cmd - - args - properties: - cmd: - type: string - enum: [calibration_cmd] - args: - type: object - required: - - levelingDetection - - vibrationCompensation - properties: - levelingDetection: - type: string - vibrationCompensation: - type: string - StreamCtrlCmd: - type: object - description: control the camera state - required: - - cmd - - args - properties: - cmd: - type: string - enum: [streamCtrl_cmd] - args: - type: object - required: - - action - properties: - action: - type: string - UserProfileCmd: - type: object - description: best guess... cloud account management - required: - - cmd - - args - properties: - cmd: - type: string - enum: [userProfile_cmd] - args: - type: object - required: - - name - - avatar - properties: - name: - type: string - avatar: - type: string - DeviceUnregisterCmd: - type: object - description: best guess... disables cloud account - required: - - cmd - properties: - cmd: - type: string - enum: [deviceUnregister_cmd] - StateCtrlCmd: - type: object - description: control the printer state - required: - - cmd - - args - properties: - cmd: - type: string - enum: [stateCtrl_cmd] - args: - type: object - required: - - action - properties: - action: - type: string - DeviceUpdateDetailCmd: - type: object - description: | - Check for firmware updates from FlashForge update server. - Despite the name, this command does NOT update local device details. - It queries the remote update server for firmware version information. - - This command will: - - Query all 4 firmware packages (kernel, control, library, software) - - Check for available updates on update.flashforge.com - - Retrieve version information, download URLs, and changelogs - - Return update availability without installing anything - - This command will NOT: - - Modify any local printer state - - Install firmware updates - - Update device information cache - required: - - cmd - - args - properties: - cmd: - type: string - enum: [deviceUpdateDetail_cmd] - args: - type: object - description: Empty args object (no parameters required) - NewLocalJobCmd: - type: object - description: submit a new job to the print - required: - - cmd - - args - properties: - cmd: - type: string - enum: [newLocalJob_cmd] - args: - type: object - required: - - jobId - - fileName - - printNow - - leveling - properties: - jobId: - type: string - fileName: - type: string - thumbPath: - type: string - printNow: - type: boolean - description: Start printing immediately - leveling: - type: boolean - description: Perform bed leveling before print - flowCalibration: - type: boolean - description: Perform flow calibration (AD5X only) - useMs: - type: boolean - description: Use material station (AD5X only) - tCount: - type: integer - description: Material/tool count (AD5X only) diff --git a/endpoints/endpoints_ad5x_1.1.7.yaml b/endpoints/endpoints_ad5x_1.1.7.yaml deleted file mode 100644 index 709b085..0000000 --- a/endpoints/endpoints_ad5x_1.1.7.yaml +++ /dev/null @@ -1,1060 +0,0 @@ -paths: - /checkCode: - post: - description: >- - Validate serialNumber + checkCode for LAN access. Returns code/message. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - - checkCode - properties: - serialNumber: - type: string - checkCode: - type: string - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/CodeMessageResponse' - /detail: - post: - description: >- - Gets info on the current status and config of the printer. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - - checkCode - properties: - serialNumber: - type: string - checkCode: - type: string - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/DetailResponse' - /product: - post: - description: >- - Returns product capability/control state flags. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - - checkCode - properties: - serialNumber: - type: string - checkCode: - type: string - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/ProductResponse' - /control: - post: - description: >- - Send a command to the printer. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - - checkCode - - payload - properties: - serialNumber: - type: string - checkCode: - type: string - payload: - oneOf: - - $ref: "#/components/schemas/LightControlCmd" - - $ref: "#/components/schemas/CirculateCtlCmd" - - $ref: "#/components/schemas/TemperatureCtlCmd" - - $ref: "#/components/schemas/PrinterCtlCmd" - - $ref: "#/components/schemas/JobCtlCmd" - - $ref: "#/components/schemas/DelayCloseCmd" - - $ref: "#/components/schemas/ReNameCmd" - - $ref: "#/components/schemas/CalibrationCmd" - - $ref: "#/components/schemas/StreamCtrlCmd" - - $ref: "#/components/schemas/UserProfileCmd" - - $ref: "#/components/schemas/MsConfigCmd" - - $ref: "#/components/schemas/IpdMsConfigCmd" - - $ref: "#/components/schemas/MsCmd" - - $ref: "#/components/schemas/IpdMsCmd" - - $ref: "#/components/schemas/MoveCtrlCmd" - - $ref: "#/components/schemas/ExtrudeCtrlCmd" - - $ref: "#/components/schemas/HomingCtrlCmd" - - $ref: "#/components/schemas/ErrorCodeCtrlCmd" - - $ref: "#/components/schemas/StateCtrlCmd" - - $ref: "#/components/schemas/DeviceUnregisterCmd" - - $ref: "#/components/schemas/DeviceUpdateDetailCmd" - - $ref: "#/components/schemas/NewJobCmd" - - $ref: "#/components/schemas/NewLocalJobCmd" - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/CodeMessageResponse' - /gcodeList: - post: - description: >- - Returns a list of printable files from /data (extensions: .g, .gx, .gcode, .3mf). - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - - checkCode - properties: - serialNumber: - type: string - checkCode: - type: string - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GcodeListResponse' - /gcodeThumb: - post: - description: >- - Returns a base64 thumbnail for a given fileName. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - - checkCode - - fileName - properties: - serialNumber: - type: string - checkCode: - type: string - fileName: - type: string - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GcodeThumbResponse' - /printGcode: - post: - description: >- - Start printing a file from /data. Returns code/message. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - - checkCode - - fileName - - levelingBeforePrint - properties: - serialNumber: - type: string - checkCode: - type: string - fileName: - type: string - levelingBeforePrint: - type: boolean - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/CodeMessageResponse' - /getThum: - get: - description: >- - Returns current print thumbnail as image/bmp. On error returns JSON code/message. - responses: - '200': - content: - image/bmp: - schema: - type: string - format: binary - application/json: - schema: - $ref: '#/components/schemas/CodeMessageResponse' - /notifyWanBind: - post: - description: >- - Notify WAN bind. Validates serialNumber and triggers a bind event on success. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - properties: - serialNumber: - type: string - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/CodeMessageResponse' - /uploadGcode: - post: - description: >- - Upload a gcode/3mf file to local storage via multipart/form-data. - Requires headers for serialNumber/checkCode/fileSize/printNow/levelingBeforePrint, plus AD5X - material-station options (flowCalibration/firstLayerInspection/timeLapseVideo/useMatlStation/gcodeToolCnt/materialMappings). - parameters: - - in: header - name: serialNumber - required: true - schema: - type: string - - in: header - name: checkCode - required: true - schema: - type: string - - in: header - name: fileSize - required: true - schema: - type: integer - description: File size in bytes - - in: header - name: printNow - required: true - schema: - type: string - description: String boolean, typically "0" or "1" - - in: header - name: levelingBeforePrint - required: true - schema: - type: string - description: String boolean, typically "0" or "1" - - in: header - name: flowCalibration - required: false - schema: - type: string - description: String boolean, typically "0" or "1" (AD5X only) - - in: header - name: firstLayerInspection - required: false - schema: - type: string - description: String boolean, typically "0" or "1" (AD5X only) - - in: header - name: timeLapseVideo - required: false - schema: - type: string - description: String boolean, typically "0" or "1" (AD5X only) - - in: header - name: useMatlStation - required: false - schema: - type: string - description: String boolean, typically "0" or "1" (AD5X only) - - in: header - name: gcodeToolCnt - required: false - schema: - type: integer - description: Number of tool channels in gcode (AD5X only, max 4) - - in: header - name: materialMappings - required: false - schema: - type: string - description: Base64-encoded JSON array of {toolId, slotId} objects (AD5X only, used when useMatlStation=1) - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - required: - - gcodeFile - properties: - gcodeFile: - type: string - format: binary - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/CodeMessageResponse' -components: - schemas: - DetailResponse: - type: object - properties: - code: - type: integer - message: - type: string - detail: - $ref: '#/components/schemas/Details' - IsOpenEnum: - type: string - enum: - - open - - close - CodeMessageResponse: - type: object - properties: - code: - type: integer - message: - type: string - ProductResponse: - type: object - properties: - code: - type: integer - message: - type: string - product: - type: object - properties: - nozzleTempCtrlState: - type: integer - chamberTempCtrlState: - type: integer - platformTempCtrlState: - type: integer - lightCtrlState: - type: integer - internalFanCtrlState: - type: integer - externalFanCtrlState: - type: integer - GcodeListResponse: - type: object - properties: - code: - type: integer - message: - type: string - gcodeList: - type: array - items: - type: string - GcodeThumbResponse: - type: object - properties: - code: - type: integer - message: - type: string - imageData: - type: string - description: Base64-encoded thumbnail image - Details: - type: object - description: Verified in AD5X 1.1.7 (request_detail), based on 3.2.7 + AD5X additions. - properties: - autoShutdown: - $ref: "#/components/schemas/IsOpenEnum" - autoShutdownTime: - type: integer - cameraStreamUrl: - type: string - description: Empty if camera disabled; otherwise http://:8080/?action=stream. - chamberFanSpeed: - type: integer - chamberTargetTemp: - type: number - chamberTemp: - type: number - coolingFanSpeed: - type: integer - coolingFanLeftSpeed: - type: integer - description: AD5X only. Left cooling fan speed (0 when idle). - clearFanStatus: - $ref: "#/components/schemas/IsOpenEnum" - description: AD5X only. Always "open" in request_detail. - coordinate: - type: array - items: - type: number - description: AD5X only. XYZ coordinate array. - camera: - type: integer - description: AD5X only. Camera availability flag (1/2). - moveCtrl: - type: integer - description: AD5X only. Always 1. - extrudeCtrl: - type: integer - description: AD5X only. Always 1. - cumulativeFilament: - type: number - cumulativePrintTime: - type: integer - currentPrintSpeed: - type: integer - doorStatus: - $ref: "#/components/schemas/IsOpenEnum" - errorCode: - type: string - estimatedLeftLen: - type: number - estimatedLeftWeight: - type: number - estimatedRightLen: - type: number - estimatedRightWeight: - type: number - estimatedTime: - type: number - externalFanStatus: - $ref: "#/components/schemas/IsOpenEnum" - fillAmount: - type: integer - firmwareVersion: - type: string - flashRegisterCode: - type: string - internalFanStatus: - $ref: "#/components/schemas/IsOpenEnum" - ipAddr: - type: string - leftFilamentType: - type: string - hasMatlStation: - type: integer - description: AD5X only. 0/1 flag for material station presence. - matlStationInfo: - $ref: "#/components/schemas/MatlStationInfo" - indepMatlInfo: - $ref: "#/components/schemas/IndepMatlInfo" - leftTargetTemp: - type: number - leftTemp: - type: number - lightStatus: - $ref: "#/components/schemas/IsOpenEnum" - location: - type: string - macAddr: - type: string - measure: - type: string - name: - type: string - nozzleCnt: - type: integer - nozzleModel: - type: string - nozzleStyle: - type: integer - pid: - type: integer - description: AD5X uses "0026" -> 26. - platTargetTemp: - type: number - platTemp: - type: number - polarRegisterCode: - type: string - printDuration: - type: integer - printFileName: - type: string - printFileThumbUrl: - type: string - description: Empty if no thumb; otherwise http://:8898/getThum. Base file dir is /data/. - printLayer: - type: integer - printProgress: - type: number - printSpeedAdjust: - type: number - remainingDiskSpace: - type: number - rightFilamentType: - type: string - rightTargetTemp: - type: number - rightTemp: - type: number - status: - type: string - targetPrintLayer: - type: integer - tvoc: - type: integer - zAxisCompensation: - type: number - MatlStationInfo: - type: object - description: AD5X material station summary. - properties: - slotCnt: - type: integer - description: Always 4. - currentSlot: - type: integer - description: Current slot id (from SerialObject material operation field +0x344). - currentLoadSlot: - type: integer - description: Current load/unload slot id (from SerialObject material operation field +0x348). - stateAction: - type: integer - description: >- - Material operation stage. Observed values: 0=idle/cancel, 2=load step 2, 3=load step 3, - 4=unload step 2, 5=unload step 3, 6=complete. - stateStep: - type: integer - description: >- - Material operation mode. Observed values: 0=idle, 1=load, 2=unload, 3=cancel. - slotInfos: - type: array - items: - $ref: "#/components/schemas/MatlSlotInfo" - MatlSlotInfo: - type: object - description: AD5X material station slot info. - properties: - slotId: - type: integer - hasFilament: - type: boolean - materialName: - type: string - materialColor: - type: string - description: Color string before first ';' in config. - IndepMatlInfo: - type: object - description: AD5X independent material info. - properties: - stateAction: - type: integer - description: >- - Material operation stage. Observed values: 0=idle/cancel, 2=load step 2, 3=load step 3, - 4=unload step 2, 5=unload step 3, 6=complete. - stateStep: - type: integer - description: >- - Material operation mode. Observed values: 0=idle, 1=load, 2=unload, 3=cancel. - materialName: - type: string - materialColor: - type: string - description: Color string before first ';' in config. - JobInfo: - type: array - description: | - Array of job tuples. Structure: `[[deviceSN, jobID, filepath], ...]`. - Firmware iterates this array looking for a `deviceSN` (index 0) match with the local printer. - items: - type: array - items: - type: string - NewJobCmd: - type: object - description: Start a cloud/multi-device job. - required: - - cmd - - args - properties: - cmd: - type: string - enum: [newJob_cmd] - args: - type: object - required: - - jobInfo - properties: - jobRouter: - type: boolean - description: | - **Presence Flag**: The value (true/false) is ignored. If this key is *present* in the JSON, - the firmware extracts the filepath from `jobInfo` tuple index 2. - If *absent*, it uses the root `filepath` property. - jobInfo: - type: array - items: - - $ref: "#/components/schemas/JobInfo" - filepath: - type: string - description: only read if jobRouter is not declared - filename: - type: string - thumbPath: - type: string - fileMd5: - type: string - printNow: - type: boolean - leveling: - type: boolean - PrinterCtlCmd: - type: object - description: | - Set speeds, fans, and z-axis compensation. - **Partial Update Behavior:** This command supports partial updates. Fields omitted from the payload - are ignored by the firmware (using internal sentinel values, e.g., -200). - **Important:** Do NOT send default values (like 0) for fields you do not intend to change, - as this will overwrite the user's current settings. - required: - - cmd - - args - properties: - cmd: - type: string - enum: [printerCtl_cmd] - args: - type: object - properties: - zAxisCompensation: - type: number - default: -200.0 - description: | - Z-offset in mm. Maps to `SET_GCODE_OFFSET Z= MOVE=1`. - Range: -5.0 to +5.0. Values outside this range are ignored. - Sentinel: -200.0 (ignore). - speed: - type: integer - default: -200 - description: | - Print speed percentage. Maps to `M220 S`. - Clamped range: 50-150. - Sentinel: -200 (ignore). - chamberFan: - type: integer - default: -200 - description: Chamber fan speed (0-255, 0=off). Sentinel -200 (ignore). - coolingFan: - type: integer - default: -200 - description: Cooling fan speed (0-255, 0=off). Sentinel -200 (ignore). - JobCtlCmd: - type: object - description: Control print jobs (pause, resume, cancel) - required: - - cmd - - args - properties: - cmd: - type: string - enum: [jobCtl_cmd] - args: - type: object - required: - - jobID - - action - properties: - jobID: - type: string - description: Job identifier - action: - type: string - description: Control action - enum: [pause, continue, resume, cancel, stop] - DelayCloseCmd: - type: object - description: configure automatic power-off - required: - - cmd - - args - properties: - cmd: - type: string - enum: [delayClose_cmd] - args: - type: object - required: - - automaticShutdown - - shutdownAfterTime - properties: - automaticShutdown: - type: string - shutdownAfterTime: - type: integer - ReNameCmd: - type: object - description: give your printer a nickname - required: - - cmd - - args - properties: - cmd: - type: string - enum: [reName_cmd] - args: - type: object - required: - - name - properties: - name: - type: string - CalibrationCmd: - type: object - description: configure leveling and vibration compensation - required: - - cmd - - args - properties: - cmd: - type: string - enum: [calibration_cmd] - args: - type: object - required: - - levelingDetection - - vibrationCompensation - properties: - levelingDetection: - type: string - vibrationCompensation: - type: string - StreamCtrlCmd: - type: object - description: control the camera state - required: - - cmd - - args - properties: - cmd: - type: string - enum: [streamCtrl_cmd] - args: - type: object - required: - - action - properties: - action: - type: string - UserProfileCmd: - type: object - description: best guess... cloud account management - required: - - cmd - - args - properties: - cmd: - type: string - enum: [userProfile_cmd] - args: - type: object - required: - - name - - avatar - properties: - name: - type: string - avatar: - type: string - MsConfigCmd: - type: object - description: | - AD5X only. Configure material station slot metadata. - **Validation:** Firmware performs NO validation on strings. - **Palette:** UI recognizes 24 specific hex codes (e.g., #FFFFFF, #FEF043, #161616). - **Types:** UI supports PLA, PLA-CF, ABS, PETG, PETG-CF, TPU, SILK. Hidden support for PA-CF, PC, etc. - required: - - cmd - - args - properties: - cmd: - type: string - enum: [msConfig_cmd] - args: - type: object - required: - - slot - - mt - - rgb - properties: - slot: - type: integer - description: Material station slot (1-4). - mt: - type: string - description: Material name/type string (e.g. "PLA"). Arbitrary strings accepted. - rgb: - type: string - description: Material color string (e.g. "#FF0000"). Arbitrary strings accepted. - IpdMsConfigCmd: - type: object - description: | - AD5X only. Configure independent material metadata. - **Validation:** Firmware performs NO validation on strings. - **Palette:** UI recognizes 24 specific hex codes. - required: - - cmd - - args - properties: - cmd: - type: string - enum: [ipdMsConfig_cmd] - args: - type: object - required: - - mt - - rgb - properties: - mt: - type: string - description: Material name/type string (e.g. "PLA"). Arbitrary strings accepted. - rgb: - type: string - description: Material color string (e.g. "#FF0000"). Arbitrary strings accepted. - MsCmd: - type: object - description: AD5X only. Material station command. - required: - - cmd - - args - properties: - cmd: - type: string - enum: [ms_cmd] - args: - type: object - required: - - action - - slot - properties: - action: - type: integer - enum: [0, 1, 2] - description: 0=load, 1=unload, 2=cancel. - slot: - type: integer - IpdMsCmd: - type: object - description: AD5X only. Independent material station command. - required: - - cmd - - args - properties: - cmd: - type: string - enum: [ipdMs_cmd] - args: - type: object - required: - - action - properties: - action: - type: integer - enum: [0, 1, 2] - description: 0=load, 1=unload, 2=cancel. - MoveCtrlCmd: - type: object - description: AD5X only. Manual axis move. - required: - - cmd - - args - properties: - cmd: - type: string - enum: [moveCtrl_cmd] - args: - type: object - required: - - axis - - delta - properties: - axis: - type: string - enum: [x, y, z] - description: Axis identifier (lowercase). - delta: - type: number - description: Move distance (float). - ExtrudeCtrlCmd: - type: object - description: AD5X only. Manual extruder move. - required: - - cmd - - args - properties: - cmd: - type: string - enum: [extrudeCtrl_cmd] - args: - type: object - required: - - axis - - delta - properties: - axis: - type: string - description: Axis identifier (ignored by handler). - delta: - type: number - description: Extrude/retract distance (float). - HomingCtrlCmd: - type: object - description: AD5X only. Home all axes. - required: - - cmd - properties: - cmd: - type: string - enum: [homingCtrl_cmd] - args: - type: object - description: Optional/unused by firmware. - ErrorCodeCtrlCmd: - type: object - description: AD5X only. Clear printer error code. - required: - - cmd - - args - properties: - cmd: - type: string - enum: [errorCodeCtrl_cmd] - args: - type: object - required: - - action - - errorCode - properties: - action: - type: string - enum: [clearErrorCode] - description: Must be clearErrorCode; handler clears only if errorCode matches current error. - errorCode: - type: string - DeviceUnregisterCmd: - type: object - description: best guess... disables cloud account - required: - - cmd - properties: - cmd: - type: string - enum: [deviceUnregister_cmd] - StateCtrlCmd: - type: object - description: control the printer state - required: - - cmd - - args - properties: - cmd: - type: string - enum: [stateCtrl_cmd] - args: - type: object - required: - - action - properties: - action: - type: string - DeviceUpdateDetailCmd: - type: object - description: | - Check for firmware updates from FlashForge update server. - Despite the name, this command does NOT update local device details. - It queries the remote update server for firmware version information. - - This command will: - - Query all 4 firmware packages (kernel, control, library, software) - - Check for available updates on update.flashforge.com - - Retrieve version information, download URLs, and changelogs - - Return update availability without installing anything - - This command will NOT: - - Modify any local printer state - - Install firmware updates - - Update device information cache - required: - - cmd - - args - properties: - cmd: - type: string - enum: [deviceUpdateDetail_cmd] - args: - type: object - description: Empty args object (no parameters required) - NewLocalJobCmd: - type: object - description: submit a new job to the print - required: - - cmd - - args - properties: - cmd: - type: string - enum: [newLocalJob_cmd] - args: - type: object - required: - - jobId - - fileName - - printNow - - leveling - properties: - jobId: - type: string - fileName: - type: string - thumbPath: - type: string - printNow: - type: boolean - description: Start printing immediately - leveling: - type: boolean - description: Perform bed leveling before print - flowCalibration: - type: boolean - description: Perform flow calibration (AD5X only) - useMs: - type: boolean - description: Use material station (AD5X only) - tCount: - type: integer - description: Material/tool count (AD5X only) diff --git a/endpoints/endpoints_ad5x_1.2.1.yaml b/endpoints/endpoints_ad5x_1.2.1.yaml deleted file mode 100644 index 709b085..0000000 --- a/endpoints/endpoints_ad5x_1.2.1.yaml +++ /dev/null @@ -1,1060 +0,0 @@ -paths: - /checkCode: - post: - description: >- - Validate serialNumber + checkCode for LAN access. Returns code/message. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - - checkCode - properties: - serialNumber: - type: string - checkCode: - type: string - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/CodeMessageResponse' - /detail: - post: - description: >- - Gets info on the current status and config of the printer. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - - checkCode - properties: - serialNumber: - type: string - checkCode: - type: string - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/DetailResponse' - /product: - post: - description: >- - Returns product capability/control state flags. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - - checkCode - properties: - serialNumber: - type: string - checkCode: - type: string - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/ProductResponse' - /control: - post: - description: >- - Send a command to the printer. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - - checkCode - - payload - properties: - serialNumber: - type: string - checkCode: - type: string - payload: - oneOf: - - $ref: "#/components/schemas/LightControlCmd" - - $ref: "#/components/schemas/CirculateCtlCmd" - - $ref: "#/components/schemas/TemperatureCtlCmd" - - $ref: "#/components/schemas/PrinterCtlCmd" - - $ref: "#/components/schemas/JobCtlCmd" - - $ref: "#/components/schemas/DelayCloseCmd" - - $ref: "#/components/schemas/ReNameCmd" - - $ref: "#/components/schemas/CalibrationCmd" - - $ref: "#/components/schemas/StreamCtrlCmd" - - $ref: "#/components/schemas/UserProfileCmd" - - $ref: "#/components/schemas/MsConfigCmd" - - $ref: "#/components/schemas/IpdMsConfigCmd" - - $ref: "#/components/schemas/MsCmd" - - $ref: "#/components/schemas/IpdMsCmd" - - $ref: "#/components/schemas/MoveCtrlCmd" - - $ref: "#/components/schemas/ExtrudeCtrlCmd" - - $ref: "#/components/schemas/HomingCtrlCmd" - - $ref: "#/components/schemas/ErrorCodeCtrlCmd" - - $ref: "#/components/schemas/StateCtrlCmd" - - $ref: "#/components/schemas/DeviceUnregisterCmd" - - $ref: "#/components/schemas/DeviceUpdateDetailCmd" - - $ref: "#/components/schemas/NewJobCmd" - - $ref: "#/components/schemas/NewLocalJobCmd" - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/CodeMessageResponse' - /gcodeList: - post: - description: >- - Returns a list of printable files from /data (extensions: .g, .gx, .gcode, .3mf). - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - - checkCode - properties: - serialNumber: - type: string - checkCode: - type: string - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GcodeListResponse' - /gcodeThumb: - post: - description: >- - Returns a base64 thumbnail for a given fileName. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - - checkCode - - fileName - properties: - serialNumber: - type: string - checkCode: - type: string - fileName: - type: string - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GcodeThumbResponse' - /printGcode: - post: - description: >- - Start printing a file from /data. Returns code/message. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - - checkCode - - fileName - - levelingBeforePrint - properties: - serialNumber: - type: string - checkCode: - type: string - fileName: - type: string - levelingBeforePrint: - type: boolean - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/CodeMessageResponse' - /getThum: - get: - description: >- - Returns current print thumbnail as image/bmp. On error returns JSON code/message. - responses: - '200': - content: - image/bmp: - schema: - type: string - format: binary - application/json: - schema: - $ref: '#/components/schemas/CodeMessageResponse' - /notifyWanBind: - post: - description: >- - Notify WAN bind. Validates serialNumber and triggers a bind event on success. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - serialNumber - properties: - serialNumber: - type: string - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/CodeMessageResponse' - /uploadGcode: - post: - description: >- - Upload a gcode/3mf file to local storage via multipart/form-data. - Requires headers for serialNumber/checkCode/fileSize/printNow/levelingBeforePrint, plus AD5X - material-station options (flowCalibration/firstLayerInspection/timeLapseVideo/useMatlStation/gcodeToolCnt/materialMappings). - parameters: - - in: header - name: serialNumber - required: true - schema: - type: string - - in: header - name: checkCode - required: true - schema: - type: string - - in: header - name: fileSize - required: true - schema: - type: integer - description: File size in bytes - - in: header - name: printNow - required: true - schema: - type: string - description: String boolean, typically "0" or "1" - - in: header - name: levelingBeforePrint - required: true - schema: - type: string - description: String boolean, typically "0" or "1" - - in: header - name: flowCalibration - required: false - schema: - type: string - description: String boolean, typically "0" or "1" (AD5X only) - - in: header - name: firstLayerInspection - required: false - schema: - type: string - description: String boolean, typically "0" or "1" (AD5X only) - - in: header - name: timeLapseVideo - required: false - schema: - type: string - description: String boolean, typically "0" or "1" (AD5X only) - - in: header - name: useMatlStation - required: false - schema: - type: string - description: String boolean, typically "0" or "1" (AD5X only) - - in: header - name: gcodeToolCnt - required: false - schema: - type: integer - description: Number of tool channels in gcode (AD5X only, max 4) - - in: header - name: materialMappings - required: false - schema: - type: string - description: Base64-encoded JSON array of {toolId, slotId} objects (AD5X only, used when useMatlStation=1) - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - required: - - gcodeFile - properties: - gcodeFile: - type: string - format: binary - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/CodeMessageResponse' -components: - schemas: - DetailResponse: - type: object - properties: - code: - type: integer - message: - type: string - detail: - $ref: '#/components/schemas/Details' - IsOpenEnum: - type: string - enum: - - open - - close - CodeMessageResponse: - type: object - properties: - code: - type: integer - message: - type: string - ProductResponse: - type: object - properties: - code: - type: integer - message: - type: string - product: - type: object - properties: - nozzleTempCtrlState: - type: integer - chamberTempCtrlState: - type: integer - platformTempCtrlState: - type: integer - lightCtrlState: - type: integer - internalFanCtrlState: - type: integer - externalFanCtrlState: - type: integer - GcodeListResponse: - type: object - properties: - code: - type: integer - message: - type: string - gcodeList: - type: array - items: - type: string - GcodeThumbResponse: - type: object - properties: - code: - type: integer - message: - type: string - imageData: - type: string - description: Base64-encoded thumbnail image - Details: - type: object - description: Verified in AD5X 1.1.7 (request_detail), based on 3.2.7 + AD5X additions. - properties: - autoShutdown: - $ref: "#/components/schemas/IsOpenEnum" - autoShutdownTime: - type: integer - cameraStreamUrl: - type: string - description: Empty if camera disabled; otherwise http://:8080/?action=stream. - chamberFanSpeed: - type: integer - chamberTargetTemp: - type: number - chamberTemp: - type: number - coolingFanSpeed: - type: integer - coolingFanLeftSpeed: - type: integer - description: AD5X only. Left cooling fan speed (0 when idle). - clearFanStatus: - $ref: "#/components/schemas/IsOpenEnum" - description: AD5X only. Always "open" in request_detail. - coordinate: - type: array - items: - type: number - description: AD5X only. XYZ coordinate array. - camera: - type: integer - description: AD5X only. Camera availability flag (1/2). - moveCtrl: - type: integer - description: AD5X only. Always 1. - extrudeCtrl: - type: integer - description: AD5X only. Always 1. - cumulativeFilament: - type: number - cumulativePrintTime: - type: integer - currentPrintSpeed: - type: integer - doorStatus: - $ref: "#/components/schemas/IsOpenEnum" - errorCode: - type: string - estimatedLeftLen: - type: number - estimatedLeftWeight: - type: number - estimatedRightLen: - type: number - estimatedRightWeight: - type: number - estimatedTime: - type: number - externalFanStatus: - $ref: "#/components/schemas/IsOpenEnum" - fillAmount: - type: integer - firmwareVersion: - type: string - flashRegisterCode: - type: string - internalFanStatus: - $ref: "#/components/schemas/IsOpenEnum" - ipAddr: - type: string - leftFilamentType: - type: string - hasMatlStation: - type: integer - description: AD5X only. 0/1 flag for material station presence. - matlStationInfo: - $ref: "#/components/schemas/MatlStationInfo" - indepMatlInfo: - $ref: "#/components/schemas/IndepMatlInfo" - leftTargetTemp: - type: number - leftTemp: - type: number - lightStatus: - $ref: "#/components/schemas/IsOpenEnum" - location: - type: string - macAddr: - type: string - measure: - type: string - name: - type: string - nozzleCnt: - type: integer - nozzleModel: - type: string - nozzleStyle: - type: integer - pid: - type: integer - description: AD5X uses "0026" -> 26. - platTargetTemp: - type: number - platTemp: - type: number - polarRegisterCode: - type: string - printDuration: - type: integer - printFileName: - type: string - printFileThumbUrl: - type: string - description: Empty if no thumb; otherwise http://:8898/getThum. Base file dir is /data/. - printLayer: - type: integer - printProgress: - type: number - printSpeedAdjust: - type: number - remainingDiskSpace: - type: number - rightFilamentType: - type: string - rightTargetTemp: - type: number - rightTemp: - type: number - status: - type: string - targetPrintLayer: - type: integer - tvoc: - type: integer - zAxisCompensation: - type: number - MatlStationInfo: - type: object - description: AD5X material station summary. - properties: - slotCnt: - type: integer - description: Always 4. - currentSlot: - type: integer - description: Current slot id (from SerialObject material operation field +0x344). - currentLoadSlot: - type: integer - description: Current load/unload slot id (from SerialObject material operation field +0x348). - stateAction: - type: integer - description: >- - Material operation stage. Observed values: 0=idle/cancel, 2=load step 2, 3=load step 3, - 4=unload step 2, 5=unload step 3, 6=complete. - stateStep: - type: integer - description: >- - Material operation mode. Observed values: 0=idle, 1=load, 2=unload, 3=cancel. - slotInfos: - type: array - items: - $ref: "#/components/schemas/MatlSlotInfo" - MatlSlotInfo: - type: object - description: AD5X material station slot info. - properties: - slotId: - type: integer - hasFilament: - type: boolean - materialName: - type: string - materialColor: - type: string - description: Color string before first ';' in config. - IndepMatlInfo: - type: object - description: AD5X independent material info. - properties: - stateAction: - type: integer - description: >- - Material operation stage. Observed values: 0=idle/cancel, 2=load step 2, 3=load step 3, - 4=unload step 2, 5=unload step 3, 6=complete. - stateStep: - type: integer - description: >- - Material operation mode. Observed values: 0=idle, 1=load, 2=unload, 3=cancel. - materialName: - type: string - materialColor: - type: string - description: Color string before first ';' in config. - JobInfo: - type: array - description: | - Array of job tuples. Structure: `[[deviceSN, jobID, filepath], ...]`. - Firmware iterates this array looking for a `deviceSN` (index 0) match with the local printer. - items: - type: array - items: - type: string - NewJobCmd: - type: object - description: Start a cloud/multi-device job. - required: - - cmd - - args - properties: - cmd: - type: string - enum: [newJob_cmd] - args: - type: object - required: - - jobInfo - properties: - jobRouter: - type: boolean - description: | - **Presence Flag**: The value (true/false) is ignored. If this key is *present* in the JSON, - the firmware extracts the filepath from `jobInfo` tuple index 2. - If *absent*, it uses the root `filepath` property. - jobInfo: - type: array - items: - - $ref: "#/components/schemas/JobInfo" - filepath: - type: string - description: only read if jobRouter is not declared - filename: - type: string - thumbPath: - type: string - fileMd5: - type: string - printNow: - type: boolean - leveling: - type: boolean - PrinterCtlCmd: - type: object - description: | - Set speeds, fans, and z-axis compensation. - **Partial Update Behavior:** This command supports partial updates. Fields omitted from the payload - are ignored by the firmware (using internal sentinel values, e.g., -200). - **Important:** Do NOT send default values (like 0) for fields you do not intend to change, - as this will overwrite the user's current settings. - required: - - cmd - - args - properties: - cmd: - type: string - enum: [printerCtl_cmd] - args: - type: object - properties: - zAxisCompensation: - type: number - default: -200.0 - description: | - Z-offset in mm. Maps to `SET_GCODE_OFFSET Z= MOVE=1`. - Range: -5.0 to +5.0. Values outside this range are ignored. - Sentinel: -200.0 (ignore). - speed: - type: integer - default: -200 - description: | - Print speed percentage. Maps to `M220 S`. - Clamped range: 50-150. - Sentinel: -200 (ignore). - chamberFan: - type: integer - default: -200 - description: Chamber fan speed (0-255, 0=off). Sentinel -200 (ignore). - coolingFan: - type: integer - default: -200 - description: Cooling fan speed (0-255, 0=off). Sentinel -200 (ignore). - JobCtlCmd: - type: object - description: Control print jobs (pause, resume, cancel) - required: - - cmd - - args - properties: - cmd: - type: string - enum: [jobCtl_cmd] - args: - type: object - required: - - jobID - - action - properties: - jobID: - type: string - description: Job identifier - action: - type: string - description: Control action - enum: [pause, continue, resume, cancel, stop] - DelayCloseCmd: - type: object - description: configure automatic power-off - required: - - cmd - - args - properties: - cmd: - type: string - enum: [delayClose_cmd] - args: - type: object - required: - - automaticShutdown - - shutdownAfterTime - properties: - automaticShutdown: - type: string - shutdownAfterTime: - type: integer - ReNameCmd: - type: object - description: give your printer a nickname - required: - - cmd - - args - properties: - cmd: - type: string - enum: [reName_cmd] - args: - type: object - required: - - name - properties: - name: - type: string - CalibrationCmd: - type: object - description: configure leveling and vibration compensation - required: - - cmd - - args - properties: - cmd: - type: string - enum: [calibration_cmd] - args: - type: object - required: - - levelingDetection - - vibrationCompensation - properties: - levelingDetection: - type: string - vibrationCompensation: - type: string - StreamCtrlCmd: - type: object - description: control the camera state - required: - - cmd - - args - properties: - cmd: - type: string - enum: [streamCtrl_cmd] - args: - type: object - required: - - action - properties: - action: - type: string - UserProfileCmd: - type: object - description: best guess... cloud account management - required: - - cmd - - args - properties: - cmd: - type: string - enum: [userProfile_cmd] - args: - type: object - required: - - name - - avatar - properties: - name: - type: string - avatar: - type: string - MsConfigCmd: - type: object - description: | - AD5X only. Configure material station slot metadata. - **Validation:** Firmware performs NO validation on strings. - **Palette:** UI recognizes 24 specific hex codes (e.g., #FFFFFF, #FEF043, #161616). - **Types:** UI supports PLA, PLA-CF, ABS, PETG, PETG-CF, TPU, SILK. Hidden support for PA-CF, PC, etc. - required: - - cmd - - args - properties: - cmd: - type: string - enum: [msConfig_cmd] - args: - type: object - required: - - slot - - mt - - rgb - properties: - slot: - type: integer - description: Material station slot (1-4). - mt: - type: string - description: Material name/type string (e.g. "PLA"). Arbitrary strings accepted. - rgb: - type: string - description: Material color string (e.g. "#FF0000"). Arbitrary strings accepted. - IpdMsConfigCmd: - type: object - description: | - AD5X only. Configure independent material metadata. - **Validation:** Firmware performs NO validation on strings. - **Palette:** UI recognizes 24 specific hex codes. - required: - - cmd - - args - properties: - cmd: - type: string - enum: [ipdMsConfig_cmd] - args: - type: object - required: - - mt - - rgb - properties: - mt: - type: string - description: Material name/type string (e.g. "PLA"). Arbitrary strings accepted. - rgb: - type: string - description: Material color string (e.g. "#FF0000"). Arbitrary strings accepted. - MsCmd: - type: object - description: AD5X only. Material station command. - required: - - cmd - - args - properties: - cmd: - type: string - enum: [ms_cmd] - args: - type: object - required: - - action - - slot - properties: - action: - type: integer - enum: [0, 1, 2] - description: 0=load, 1=unload, 2=cancel. - slot: - type: integer - IpdMsCmd: - type: object - description: AD5X only. Independent material station command. - required: - - cmd - - args - properties: - cmd: - type: string - enum: [ipdMs_cmd] - args: - type: object - required: - - action - properties: - action: - type: integer - enum: [0, 1, 2] - description: 0=load, 1=unload, 2=cancel. - MoveCtrlCmd: - type: object - description: AD5X only. Manual axis move. - required: - - cmd - - args - properties: - cmd: - type: string - enum: [moveCtrl_cmd] - args: - type: object - required: - - axis - - delta - properties: - axis: - type: string - enum: [x, y, z] - description: Axis identifier (lowercase). - delta: - type: number - description: Move distance (float). - ExtrudeCtrlCmd: - type: object - description: AD5X only. Manual extruder move. - required: - - cmd - - args - properties: - cmd: - type: string - enum: [extrudeCtrl_cmd] - args: - type: object - required: - - axis - - delta - properties: - axis: - type: string - description: Axis identifier (ignored by handler). - delta: - type: number - description: Extrude/retract distance (float). - HomingCtrlCmd: - type: object - description: AD5X only. Home all axes. - required: - - cmd - properties: - cmd: - type: string - enum: [homingCtrl_cmd] - args: - type: object - description: Optional/unused by firmware. - ErrorCodeCtrlCmd: - type: object - description: AD5X only. Clear printer error code. - required: - - cmd - - args - properties: - cmd: - type: string - enum: [errorCodeCtrl_cmd] - args: - type: object - required: - - action - - errorCode - properties: - action: - type: string - enum: [clearErrorCode] - description: Must be clearErrorCode; handler clears only if errorCode matches current error. - errorCode: - type: string - DeviceUnregisterCmd: - type: object - description: best guess... disables cloud account - required: - - cmd - properties: - cmd: - type: string - enum: [deviceUnregister_cmd] - StateCtrlCmd: - type: object - description: control the printer state - required: - - cmd - - args - properties: - cmd: - type: string - enum: [stateCtrl_cmd] - args: - type: object - required: - - action - properties: - action: - type: string - DeviceUpdateDetailCmd: - type: object - description: | - Check for firmware updates from FlashForge update server. - Despite the name, this command does NOT update local device details. - It queries the remote update server for firmware version information. - - This command will: - - Query all 4 firmware packages (kernel, control, library, software) - - Check for available updates on update.flashforge.com - - Retrieve version information, download URLs, and changelogs - - Return update availability without installing anything - - This command will NOT: - - Modify any local printer state - - Install firmware updates - - Update device information cache - required: - - cmd - - args - properties: - cmd: - type: string - enum: [deviceUpdateDetail_cmd] - args: - type: object - description: Empty args object (no parameters required) - NewLocalJobCmd: - type: object - description: submit a new job to the print - required: - - cmd - - args - properties: - cmd: - type: string - enum: [newLocalJob_cmd] - args: - type: object - required: - - jobId - - fileName - - printNow - - leveling - properties: - jobId: - type: string - fileName: - type: string - thumbPath: - type: string - printNow: - type: boolean - description: Start printing immediately - leveling: - type: boolean - description: Perform bed leveling before print - flowCalibration: - type: boolean - description: Perform flow calibration (AD5X only) - useMs: - type: boolean - description: Use material station (AD5X only) - tCount: - type: integer - description: Material/tool count (AD5X only) diff --git a/endpoints/networkserver_commands_adventurer3.yaml b/endpoints/networkserver_commands_adventurer3.yaml deleted file mode 100644 index 3ab0363..0000000 --- a/endpoints/networkserver_commands_adventurer3.yaml +++ /dev/null @@ -1,1244 +0,0 @@ -# FlashForge Adventurer 3 NetworkServer Protocol Specification -# Firmware: v1.3.7 20230725 -# Port: 8899 (TCP) -# Protocol: TCP Socket with line-based ASCII commands (G-code/M-code) - -description: >- - This file documents the proprietary and standard G-code/M-code commands supported by the - NetworkServer component of the FlashForge Adventurer 3 firmware (finder-rush-mips binary). - - **Protocol Overview:** - - Transport: TCP socket on port 8899 - - Format: Line-based ASCII commands with tilde (~) prefix - - Response: Acknowledgment prefixed with "ack:" or "echo:" - - **Command Format:** - All commands MUST be prefixed with a tilde character `~` followed by the G-code or M-code. - Example: `~M115` instead of `M115` - - **Response Format:** - Responses typically begin with `ack: ` or `echo: ` prefix followed by the response data. - Multi-line responses are terminated with `\r\n`. - - **Connection Model:** - - Multi-threaded server (one thread per client) - - Maximum 10 concurrent connections - - No authentication required - - **Critical Differences from Adventurer 4 Pro:** - - No HTTP REST API (TCP only) - - No authentication on port 8899 - - Different set of custom M-codes - - On/off fan control only (no variable speed) - - No built-in camera (accessory only) - -commands: - # --- Motion Control (G-codes) --- - - G1: - summary: Linear move to specified position - description: >- - Move the print head to the specified coordinates. This command supports up to 5 axes: - X, Y, Z (position), E (extruder), and F (feedrate). - - **Special E-parameter Handling:** - If the command contains an E parameter (extrusion), the firmware automatically - injects a "G92 E0" command before the movement to reset the extruder position. - This prevents extruder position accumulation errors during manual moves. - - **Blocked During Printing:** - This command is ignored if a print job is currently active (BuildPrint::instBuildPrint != null). - args: - X: float # X coordinate in mm (0-150mm) - Y: float # Y coordinate in mm (0-150mm) - Z: float # Z coordinate in mm (0-150mm) - E: float # Extruder position in mm (triggers G92 E0 injection) - F: int # Feedrate in mm/min - response: None (asynchronous - forwarded to motion controller) - example: "~G1 X100 Y100 Z0.3 F3000" - notes: > - - Motion blocked during active prints - - E parameter auto-injects G92 E0 before move - - No explicit acceleration control - - Coordinate system: Origin (0,0,0) at front-left corner - - G28: - summary: Auto-home all or specified axes - description: >- - Home the printer by moving axes to their endstops until triggered. - This establishes the coordinate system origin. - - **Implementation:** - Unlike other motion commands, G28 does not directly send output to the serial port. - The parser handles the homing sequence internally, likely calling motion controller - functions directly. - - **Homing Sequence (typical Cartesian printer):** - 1. Z axis homes first (moves down until trigger) - 2. X and Y home simultaneously - 3. XY moves to center position - - **Default behavior (no parameters):** Homes all axes (X, Y, Z) - args: - X: boolean # Home X axis (optional) - Y: boolean # Home Y axis (optional) - Z: boolean # Home Z axis (optional) - response: None (asynchronous - handled by parser internally) - example: "~G28" - example2: "~G28 X Y" - example3: "~G28 Z" - notes: > - - No direct serial output from TCP handler - - Parser handles homing sequence internally - - Axes can be specified in any combination - - Required before first print or after position loss - - G90: - summary: Set absolute positioning mode - description: >- - Set all subsequent G1 movement commands to use absolute coordinates from origin. - - In absolute mode, coordinates are interpreted as actual positions in the build volume. - For example, `G1 X50` will move to X=50mm regardless of current position. - - **Blocked During Printing:** - This command is ignored if a print job is currently active. - args: None - response: None (asynchronous - forwarded to motion controller) - example: "~G90" - notes: > - - Positioning mode state maintained by motion controller firmware - - Default mode on startup (likely G90) - - Opposite of G91 (relative positioning) - - G91: - summary: Set relative positioning mode - description: >- - Set all subsequent G1 movement commands to use relative offsets from current position. - - In relative mode, coordinates are interpreted as offsets from the current position. - For example, `G1 X10` will move +10mm from the current X position. - - **Blocked During Printing:** - This command is ignored if a print job is currently active. - args: None - response: None (asynchronous - forwarded to motion controller) - example: "~G91" - notes: > - - Useful for fine adjustments and calibration - - Opposite of G90 (absolute positioning) - - State tracked by motion controller, not by TCP handler - - G92: - summary: Set current position without moving - description: >- - Set the current position to specified values without moving the print head. - This resets the coordinate system to a new origin. - - **Common Uses:** - - Reset extruder position: `G92 E0` - - Set work offset for multi-part prints - - Recover from lost position after skipped steps - - Define print start position - - **Blocked During Printing:** - This command is ignored if a print job is currently active. - args: - X: float # Set X position to specified value (mm) - Y: float # Set Y position to specified value (mm) - Z: float # Set Z position to specified value (mm) - E: float # Set extruder position to specified value (mm) - response: None (asynchronous - forwarded to motion controller) - example: "~G92 E0" - example2: "~G92 X0 Y0 Z0" - example3: "~G92 X50" - notes: > - - No movement occurs, only position tracking changes - - Useful for resetting extruder before manual moves - - Can set multiple axes simultaneously - - No parameters sets all axes to current position (no-op) - - # --- Printer Control (M-codes) --- - - M17: - summary: Enable stepper motors - description: >- - Enable all stepper motors, allowing them to hold position and receive movement commands. - - **Motors Affected:** - - X axis stepper - - Y axis stepper - - Z axis stepper - - E (extruder) stepper - - **Safety Considerations:** - - Motors heat up when enabled and can overheat if left enabled without movement - - Printer holds position, consuming power - - Typical timeout: Auto-disable after period of inactivity - args: None - response: "CMD M17 Received." - example: "~M17" - notes: > - - Motors can overheat if left enabled without movement - - Z axis may free-fall if disabled (support bed before disabling!) - - Must home (G28) before printing after disabling motors - - M18: - summary: Disable stepper motors - description: >- - Disable all stepper motors, releasing holding torque and allowing free movement. - - **Motors Affected:** - - X axis stepper - - Y axis stepper - - Z axis stepper - - E (extruder) stepper - - **Use Cases:** - - Manual bed leveling - - Loading/unloading filament - - Transport/Shipping (prevent motor overheating) - - Power saving when idle - - **Safety Considerations:** - - Build plate can drop freely when Z is disabled (support it!) - - Printer loses position knowledge - G28 required before next print - - Hotend remains hot (only motors are disabled) - args: None - response: "CMD M18 Received." - example: "~M18" - notes: > - - Returns error "printing can not send M18" if print is active - - Always home (G28) before printing again after M18 - - Z axis will free-fall if not supported - - No recovery possible without re-homing - - M23: - summary: Select SD file for printing - description: >- - Select a file from the SD card storage for printing. The command opens the file, - verifies its size, and prepares it for printing. However, it does NOT start the print - automatically - you must send M24 to start. - - **Path Normalization:** - The firmware normalizes file paths by stripping these prefixes: - - `0:/user/` prefix (removed if present) - - `/data/` prefix (removed if present) - - Whitespace trimmed - Final storage: `/data/` - - **File Formats Supported:** - - `.g` - G-code files (primary format) - - `.gx` - FlashForge encrypted G-code format - - `.gcode` - Standard G-code files - - **Triggered Signals:** - - `sig_startprint()` - Emitted with full file path - - Print does NOT start automatically - requires M24 command - args: - filepath: string # File path on SD card (e.g., "model.g", "0:/user/test.g", "/data/test.g") - response: > - File opened: Size: - Done printing file - ok - example: "~M23 test_model.g" - example2: "~M23 0:/user/calibration.g" - example3: "~M23 /data/example.gcode" - notes: > - - All prefixes stripped and normalized - - Path separators: / - - Final storage always in /data/ - - File must exist and be readable - - Returns size 0 if file not found - - Must call M24 to actually start printing - - M24: - summary: Start or resume print job - description: >- - Start a print job if file selected but not started, or resume a paused print job. - - **State Machine:** - ``` - IDLE → M23 (select file) → READY → M24 → PRINTING - ↓ - PAUSED → M24 → PRINTING - ``` - - **Behavior:** - - If READY (file selected, not started): Starts printing from beginning - - If PAUSED: Resumes from paused position - - If PRINTING: No-op (already printing) - - If IDLE: Command accepted but print won't start - - **Triggered Signals:** - - `sig_printresume()` - Emitted to resume printing - args: None - response: "CMD M24 Received." - example: "~M24" - notes: > - - No-op if already printing - - Check M27 status to verify print started - - Use M25 to pause instead of cancel - - M26 will cancel the print job - - M25: - summary: Pause print job - description: >- - Immediately pause the current print job, saving current position for later resumption. - - **State Preservation:** - The pause mechanism preserves: - - XYZ coordinates (current position) - - Extruder position (E) - - Current temperatures (nozzle and bed) - - File read position (to resume from correct location) - - **Paused State:** - - Print head may park in safe location - - Heaters maintained at target temperature - - Motors may be disabled (M18) for safety - - Can be resumed with M24 - - **Triggered Signals:** - - `sig_printpause()` - Emitted to pause printing - args: None - response: "CMD M25 Received." - example: "~M25" - notes: > - - Print state saved for recovery - - Heaters remain at target temperature - - Use M24 to resume printing - - M26 will cancel print (unrecoverable) - - M26: - summary: Cancel print job - description: >- - Immediately cancel the current print operation and abort the file read. - - **Behavior:** - - Immediately cancels current print - - Aborts file read - - Disables heaters - - May home axes (depends on firmware configuration) - - File selection is cleared - - **State Transitions:** - ``` - PRINTING → M26 → IDLE - PAUSED → M26 → IDLE - READY → M26 → IDLE (no-op if no file selected) - ``` - - **Triggered Signals:** - - `sig_printcancel()` - Emitted to cancel printing - - **Warning:** This is a hard cancel - print progress is lost. Cannot resume after M26. - args: - bytePosition: int # Optional byte position for SD resume (ignored in A3) - response: "CMD M26 Received." - example: "~M26" - notes: > - - Hard stop - print cannot be resumed - - File selection cleared - - Must select file with M23 again to restart - - May home axes as part of cancel sequence - - M27: - summary: Get print status and progress - description: >- - Query the current print job progress and status. - - **Progress Format:** - The firmware uses a confusing format where "bytes" are actually percentage points: - - Current value = 0-100 (percentage complete) - - Total value = always 100 (representing 100%) - - **Response Interpretation:** - - `SD printing byte 0/0` - No print active (IDLE) - - `SD printing byte 45/100` - 45% complete, actively printing - - `SD printing byte 100/100` - Print complete or 100% complete - - **Progress Calculation:** - The firmware multiplies internal progress (0.0-1.0) by 100 to get percentage. - args: None - response: > - CMD M27 Received. - SD printing byte / - ok - example: "~M27" - notes: > - - "byte" values are percentages, not actual bytes - - Total is always 100 - - Use M105 to verify temperatures while polling - - Poll every 1-2 seconds for real-time updates - - M28: - summary: Begin file upload to SD card - description: >- - Initialize a binary file upload to the printer's SD card storage. This command - switches the connection to binary mode for receiving raw G-code data. - - **Path Format Requirements:** - - Must match regex: `^\s*(\d+)\s+(0:/user/([^\n]+))` - - File path MUST start with `0:/user/` - - Filename is extracted from path - - Final storage: `/data/` - - **Validation Steps:** - 1. Parse arguments with regex - 2. Check available disk space via `check_free_space()` - 3. Create file in `/data/` directory - 4. Show upload progress dialog on printer display - - **Binary Mode:** - After M28 succeeds, the server expects raw binary G-code data stream. - No further text commands are processed until M29 is received. - args: - filesize: int # Size of file to upload (in bytes) - filepath: string # Target path with "0:/user/" prefix (e.g., "0:/user/model.g") - response: > - CMD M28 Received. - ok (success) - - OR - - Error: Not enough space (insufficient disk space) - - OR - - Error: Cannot create file (path invalid) - example: "~M28 1234567 0:/user/newprint.gcode" - notes: > - - File size MUST be accurate (verified by M29) - - Client must have file size before starting - - Max file size limited by SD card capacity (FAT32 = 4GB) - - Recommended chunk size: 4096 bytes - - Disk space checked before accepting upload - - Upload progress dialog shown on printer display - - M29: - summary: Complete file upload and verify - description: >- - Complete the binary file upload initiated by M28 and verify the transfer. - - **Verification Process:** - 1. Compares actual uploaded file size with expected size from M28 - 2. If sizes match exactly: File is valid, keeps it - 3. If sizes differ: File is corrupted, deletes it - - **Finalization:** - On successful upload: - - Updates file timestamp via `setFileNewTime()` - - Closes upload file handle - - Closes upload dialog on printer - - Refreshes file list display (`sig_showMemoryList()`) - - **Error Handling:** - On size mismatch or other error: - - Logs critical error: "File Is Not Available" - - Deletes incomplete file via QFile::remove() - - Returns error to client - args: None - response: > - CMD M29 Received. - ok - filesize = tofile.size = - ack: "ok" (success) - - OR - - Error logged: "File Is Not Available" (size mismatch) - example: "~M29" - notes: > - - All bytes MUST be received for success - - No partial uploads accepted - - Network errors during upload = complete failure - - Delete and retry if size mismatch occurs - - File is verified before being committed - - M104: - summary: Set extruder temperature - description: >- - Set the target temperature for the hotend/nozzle. - - **Temperature Validation:** - - Maximum temperature read from extruder configuration file - - Typical maximum: 265°C (0x109 in hex) - - Requested temperatures are automatically capped at maximum - - Minimum temperature: 0°C (room temperature) - - **Offset Support:** - - Firmware supports user-configured temperature offsets - - Read from nozzle temperature different file - - Offset applied before sending to hardware - - **Heater Control:** - - Command is stored and sent to printer controller - - Hardware PID loop maintains actual temperature - - No wait-for-heat flag (asynchronous operation) - args: - S: int # Target temperature in Celsius (0-265°C) - T: int # Extruder index (default: 0, A3 has single extruder) - response: "ack: " with command echo - example: "~M104 S200" - example2: "~M104 S0" - example3: "~M104 T0 S210" - notes: > - - Temperatures above 265°C automatically clamped - - Temperature change is asynchronous (no wait) - - Use M105 to poll actual temperature - - Offset support for fine-tuning - - Minimum: 0°C (room temp) - - M105: - summary: Get current temperatures - description: >- - Query the current nozzle and bed temperatures from the printer. - - **Response Format When PRINTING:** - ``` - ok T0:/ B:/ - ``` - - **Response Format When NOT PRINTING:** - ``` - ok T0:/0 B:/ - ``` - - **Temperature Values:** - - T0: Nozzle/extruder temperature in Celsius - - B: Heated bed temperature in Celsius - - Format: `current/target` or `current/0` (no target set) - - **Thread-Safe Reading:** - All temperature getters use mutex locks for thread safety: - - `getCurrentExtruderTemp()` - Offset +0x17c - - `getTargetExtruderTemp()` - Offset +0x180 - - `getCurrentPlatformTemp()` - Offset +0x174 - - `getTargetPlatformTemp()` - Offset +0x178 - - Mutex lock at offset +0xcc - args: None - response: > - CMD M105 Received. - ok T0:/ B:/ - example: "~M105" - notes: > - - Single extruder only (T0) - - Temperatures in Celsius (integer values) - - Target shows as 0 when not printing - - Thread-safe mutex-protected reads - - Poll every 1-2 seconds for monitoring - - M106: - summary: Turn on cooling fan - description: > - Turn on the part cooling fan at full speed. - - **Adventurer 3 Fan Control:** - - **ON/OFF ONLY** - No variable speed support - - Unlike 5M series, A3 does NOT support M106 with S parameter - - Command is forwarded as "M106" with no speed parameter - - **Fan Types:** - 1. **Part Cooling Fan:** Controlled by M106/M107 - 2. **Extruder Cooling Fan:** Always on when extruder is hot (safety-critical) - args: None - response: "CMD M106 Received." - example: "~M106" - notes: > - - No variable speed control (on/off only) - - Default speed when ON: 100% (full speed) - - Extruder fan runs automatically (not controlled by M106/M107) - - Use M107 to turn off - - M107: - summary: Turn off cooling fan - description: > - Turn off the part cooling fan. - - **Behavior:** - - Fan stops spinning immediately - - No gradual ramp-down - - Extruder cooling fan continues running if hotend is hot - - **Related Command:** - M106 turns the fan back on. - args: None - response: "CMD M107 Received." - example: "~M107" - notes: > - - Part cooling fan only (not extruder fan) - - Extruder fan is automatic and cannot be controlled - - Immediate stop (no ramp-down) - - M108: - summary: Cancel heat wait (STUBBED - No effect) - description: > - **WARNING:** This command is stubbed out in Adventurer 3 firmware and has no effect. - - **Expected Behavior (standard RepRap):** - Cancel waiting for temperature to reach target and resume immediately. - - **Actual Adventurer 3 Behavior:** - - Command is logged but performs no action - - No serial communication to printer controller - - No state change - - **Workarounds:** - - Use pause/resume (M25/M24) instead - - Set new target temperatures to change heat settings - - Emergency stop with M112 if needed - args: None - response: "CMD M108 Received." - example: "~M108" - notes: > - - Stubbed out - does nothing on Adventurer 3 - - Do not rely on this command - - Use M25/M24 for print control instead - - Set temperatures to 0 to stop heating - - M112: - summary: Emergency stop - description: > - **CRITICAL SAFETY COMMAND** - Immediately halts all printer operations. - - **Emergency Stop Sequence:** - 1. M112 - Emergency stop command (immediate halt) - 2. G162 Z F500 - Home Z axis at 500 mm/min (emergency retract) - 3. M104 S0 T0 - Turn off extruder heater (tool 0 to 0°C) - 4. G162 X Y- F2000 - Home X and Y axes at 2000 mm/min - - **State Cleanup:** - - Homes all axes (losing position) - - Turns off heaters (losing temperature) - - Resets internal SerialObject state - - **Cannot recover print job** after M112 - - **Recovery After M112:** - To print again: - 1. Re-home the printer (G28) - 2. Reheat the extruder and bed - 3. Select file again (M23) - 4. Start print from beginning (M24) - 5. Optionally use "resume from layer" if firmware supports it - args: None - response: "echo: Emergency Stop!!!" (or similar) - example: "~M112" - notes: > - - CANNOT BE UNDONE - Print job is lost - - Axes are homed (position lost) - - Heaters are turned off - - Internal state is reset - - Use only for emergencies - - M114: - summary: Get current position - description: > - Query the current print head position from the printer's motion controller. - - **Response Format:** - ``` - CMD M114 Received. - X: Y: Z: A: B: - ``` - - **Position Units:** - - X, Y, Z: Millimeters (mm) - - A, B: Unknown (possibly extruder positions or temperature-related) - - **Note:** - - Extruder position is typically labeled 'E' in standard RepRap, - but Adventurer 3 firmware uses A/B in the response. - args: None - response: > - CMD M114 Received. - X:10.5 Y:20.3 Z:0.3 A:0.00 B:0.00 - example: "~M114" - notes: > - - Synchronous command (waits for motion controller) - - Units in millimeters for X, Y, Z - - A and B values purpose unclear - - Use to verify position after critical moves - - May show 'E' instead of 'A'/'B' in some firmware versions - - M115: - summary: Get firmware and printer information - description: > - Returns comprehensive printer identification including machine type, firmware version, - serial number, build dimensions, tool count, and network MAC address. - - **Response Fields:** - - Machine Type: "FlashForge Adventurer III" (hardcoded) - - Machine Name: User-assigned printer name (from SerialNoFile) - - Firmware: Version string (e.g., "v1.3.7") - - Serial Number: Factory serial number (from SerialNoFile) - - X: 150 Y: 150 Z: 150 (Build volume in mm) - - Tool Count: 1 (single extruder) - - Mac Address: Network MAC from /etc/MAC file - - **Data Sources:** - - Build volume: Hardcoded string "150 X 150 X 150" parsed into X/Y/Z - - MAC address: Read from /etc/MAC file (32 bytes, format XX:XX:XX:XX:XX:XX) - - Serial number: From configuration files via SerialNoFile class - - Firmware version: Hardcoded string "1.3.7 20230725" - args: None - response: > - echo: Machine Type: FlashForge Adventurer III - Machine Name: - Firmware: v1.3.7 - Serial Number: - X: 150 Y: 150 Z: 150 - Tool Count: 1 - Mac Address: - example: "~M115" - notes: > - - Build volume: 150x150x150mm (hardcoded) - - Firmware: v1.3.7 20230725 - - Use for printer identification - - MAC address used for network discovery - - M119: - summary: Get endstop status and printer state - description: > - Query comprehensive printer status including endstops, machine state, move mode, - filament sensor, LED status, and current print file. - - **Response Fields:** - - Endstop: X-max, Y-max, Z-min states (0=open, 1=triggered) - - MachineStatus: IDLE, PRINTING, PAUSED, HOMING, ERROR - - MoveMode: Numeric move mode value (0.0, 1.0, etc.) - - FilamentStatus: "ok" (filament present) or "no filament" - - LEDStatus: "on" or "off" - - PrintFileName: Current print file name (empty if not printing) - - **Filament Detection:** - Firmware sends M113 command internally and parses response. - Status determined by comparing second field to "1". - - **Endstop States:** - - Default values if no custom string: All axes 0 (not triggered) - - 0 = Open/not triggered - - 1 = Closed/triggered - args: None - response: > - echo: Endstop: X-max: Y-max: Z-min: - MachineStatus: - MoveMode: - FilamentStatus: - LEDStatus: - PrintFileName: - example: "~M119" - notes: > - - Endstop values: 0=open, 1=triggered - - MachineStatus: IDLE, PRINTING, PAUSED, HOMING, ERROR - - MoveMode: Numeric mode indicator - - Use for status monitoring and debugging - - M140: - summary: Set bed temperature - description: > - Set the target temperature for the heated bed. - - **Temperature Range:** - - Typical range: 0-100°C for PLA/ABS materials - - No explicit maximum in code (hardware-limited) - - Recommended maximum: ~100°C - - **State-Dependent Behavior:** - - READY: Sends command immediately - - PAUSED: Sends command immediately - - PRINTING: Queues command for later execution - - **No Offset Support:** - Unlike extruder temperature, bed temperature has no user-configurable offset. - args: - S: int # Target bed temperature in Celsius (0-100°C typical) - response: "ack: " with command echo - example: "~M140 S60" - example2: "~M140 S0" - notes: > - - Recommended: PLA=60°C, ABS=100°C - - Bed heating is asynchronous - - Use M105 to monitor actual temperature - - Queued during printing - - M144: - summary: LED control - Internal command (use M146 instead) - description: > - **Internal Command** - Use M146 for user-facing LED control. - - M144 is sent by the firmware to turn LEDs ON. - **WARNING:** This command likely writes to EEPROM. Frequent usage can degrade flash memory. - Always use M146 for safe/standard toggling. - args: None - response: "cmd_M144 " with response - example: "~M144" - notes: > - - Internal command - use M146 instead - - Turns LEDs ON - - Sent by M146 when parameter is NOT "0" - - M145: - summary: LED control - Internal command (use M146 instead) - description: > - **Internal Command** - Use M146 for user-facing LED control. - - M145 is sent by the firmware to turn LEDs OFF. - **WARNING:** This command likely writes to EEPROM. Frequent usage can degrade flash memory. - Always use M146 for safe/standard toggling. - args: None - response: "cmd_M145 " with response - example: "~M145" - notes: > - - Internal command - use M146 instead - - Turns LEDs OFF - - Sent by M146 when parameter is "0" - - M146: - summary: Control accessory LEDs (on/off) - description: > - **SAFE/PREFERRED** method to control add-on LED light bars. - - This command abstracts the internal M144/M145 calls and is the safe entry point - for LED control. - - **Base Model:** - Adventurer 3 does NOT have built-in LEDs. - This command only works if LED accessory is installed. - - **Command Mapping:** - - `M146 0` → Sends M145 to printer (LEDs OFF) - - `M146 1` → Sends M144 to printer (LEDs ON) - - `M146 255` → Sends M144 to printer (LEDs ON) - - **LED Control:** - - Only on/off control (no brightness adjustment) - - No color selection (single color) - - Controlled via M144 (ON) and M145 (OFF) commands - args: - parameter: string # "0" for OFF, any other value for ON - response: "ack: " with command echo - example: "~M146 1" - example2: "~M146 0" - notes: > - - Adventurer 3 has no built-in LEDs (accessory only) - - On/off control only, no brightness/color - - M146 0 = OFF, M146 1+ = ON - - Uses M144/M145 internally but is the standard API path - - # --- FlashForge Custom Commands (Network) --- - - M601: - summary: Initialize WiFi/multicast connection - description: > - Initialize WiFi multicast connection and set connection status. - - **Connection Types:** - - Type 1 → Multicast status: 2 - - Type 2 → Multicast status: 3 - - Other/Unknown → Multicast status: 0 - - **Behavior:** - - If already connected: Returns error "Error: have been connected" - - On success: Sets multicast status and triggers M601 signal - - Starts connection timer - - Updates connection state flags - - **Response Format:** - ``` - CMD M601 Received. - ok (success) - ``` - - ``` - Error: have been connected (already connected) - ``` - args: None - response: > - CMD M601 Received. - ok - example: "~M601" - notes: > - - Initializes multicast connection - - Error if already connected - - Network-specific command - - M602: - summary: Disconnect WiFi/multicast connection - description: > - Close WiFi/multicast connection and reset connection state. - - **Behavior:** - - Sets multicast status to 0 (disconnected) - - Triggers `sig_M602()` signal - - Resets connection state flags - - Stops connection timer - - Closes upload dialog - args: None - response: > - CMD M602 Received. - ok - example: "~M602" - notes: > - - Disconnects multicast connection - - Resets connection state - - Complements M601 - - M610: - summary: Set printer name (network identification) - description: > - Update the printer's network name for mDNS/bonjour discovery. - - **Behavior:** - - Updates printer name in serial number file - - Triggers printer name change in UI - - May reconnect to network with new name - - **Use Cases:** - - Network identification - - Printer labeling - - Bonjour/mDNS discovery - args: - printer_name: string # New name for the printer - response: > - CMD M610 Received. - ok - example: "~M610 My 3D Printer" - notes: > - - Updates serial number file (persistent) - - **Side Effect:** Triggers network service reconnection (`reconnectedMultiCase`) which may briefly drop connection. - - Confirmed by RE to be the canonical rename method. - - M611: - summary: Custom network command (implementation unclear) - description: > - FlashForge-proprietary network command for Adventurer 3. - - **Status:** Implementation not fully analyzed. Likely used for: - - Network configuration - - WiFi settings management - - Cloud service connection - - **Response:** Logs command receipt with "cmd_M611" prefix. - args: None (implementation-specific) - response: "cmd_M611 " with response - example: "~M611" - notes: > - - FlashForge proprietary command - - Details require further analysis - - M612: - summary: Custom network command (implementation unclear) - description: > - FlashForge-proprietary network command for Adventurer 3. - - **Status:** Implementation not fully analyzed. Likely used for: - - Network configuration - - WiFi settings management - - Cloud service connection - - **Response:** Logs command receipt with "cmd_M612" prefix. - args: None (implementation-specific) - response: "cmd_M612 " with response - example: "~M612" - notes: > - - FlashForge proprietary command - - Details require further analysis - - # --- FlashForge Custom Commands (Printer) --- - - M650: - summary: Get printer model identification - description: > - Return hardware version information for the Adventurer 3. - - **Response Format:** - ``` - CMD M650 Received. - X: 1.0 Y: 0.5 - ``` - - **Model ID Interpretation:** - - X: 1.0 - Major hardware revision - - Y: 0.5 - Minor hardware revision or variant - - **Purpose:** - - Printer identification - - Hardware variant detection - - Compatibility checking - - Firmware validation - - **Note:** - These values are hardcoded in the firmware, not read from hardware. - args: None - response: > - CMD M650 Received. - X: 1.0 Y: 0.5 - example: "~M650" - notes: > - - Hardware version: 1.0/0.5 - - Hardcoded in firmware - - Used for printer identification - - M651: - summary: Custom printer command (implementation unclear) - description: > - FlashForge-proprietary printer command for Adventurer 3. - - **Status:** Implementation not fully analyzed. Sent to serial port and response checked. - - **Response:** Logs command receipt with "cmd_M651" prefix. - args: None (implementation-specific) - response: "cmd_M651 " with response - example: "~M651" - notes: > - - FlashForge proprietary command - - Sent to serial port with response check - - Details require further analysis - - M652: - summary: Custom printer command (implementation unclear) - description: > - FlashForge-proprietary printer command for Adventurer 3. - - **Status:** Implementation not fully analyzed. Sent to serial port with params. - - **Response:** Logs command receipt with "cmd_M652" prefix. - args: None (implementation-specific) - response: "cmd_M652 " with response - example: "~M652" - notes: > - - FlashForge proprietary command - - Sent to serial port with params - - Details require further analysis - - M653: - summary: Custom printer command with parameters - description: > - FlashForge-proprietary printer command for Adventurer 3. - - **Status:** Implementation not fully analyzed. Accepts parameters and sends to serial port. - - **Response:** "ack: ok" on success. - args: - params: string # Command-specific parameters - response: "ack: ok" - example: "~M653 " - notes: > - - FlashForge proprietary command - - Accepts parameters - - Details require further analysis - - M654: - summary: Custom printer command with parameters - description: > - FlashForge-proprietary printer command for Adventurer 3. - - **Status:** Implementation not fully analyzed. Accepts parameters and sends to serial port. - - **Response:** "ack: ok" on success. - args: - params: string # Command-specific parameters - response: "ack: ok" - example: "~M654 " - notes: > - - FlashForge proprietary command - - Accepts parameters - - Details require further analysis - - # --- FlashForge Custom Commands (File & System) --- - - M661: - summary: List all print files on SD card - description: > - Return a list of all printable files stored on the printer's SD card (/data directory). - - **File Extension Filtering:** - Only returns files with these extensions: - - `.g` - G-code files - - `.gx` - FlashForge encrypted G-code format - - `.gcode` - Standard G-code files - - **Response Format:** - **Success:** - ``` - CMD M661 Received. - info_list.size: - - - ... - ``` - - **No Files:** - ``` - CMD M661 Error. - ``` - args: None - response: > - CMD M661 Error. - info_list.size: - - - ... - example: "~M661" - notes: > - - Searches in /data directory - - Filters by supported extensions - - Returns count followed by filenames - - **Internal Implementation:** File count is calculated via `(size >> 2) * -0x33333333`, effectively dividing the vector size by 12 bytes per entry. - - Used for file browser UI - - M662: - summary: Get thumbnail image from G-code file - description: > - Extract embedded thumbnail image from a G-code file header. - - **Image Format:** - - PNG format - - Embedded in G-code file header - - Size varies (typically ~60-800 bytes) - - **Fallback:** - If no embedded image exists, returns default image from: - `/usr/share/G_File.png` - - **Binary Data Format:** - ``` - uint32_t magic: 0x2a2a2aa2 (big-endian: 0xa2a22a2a) - uint32_t length: image data length (big-endian) - uint8_t data[length]: PNG image bytes - ``` - - **Response Format:** - **Success:** - ``` - CMD M662 Received. - ack header length: - - ``` - - **Error:** - ``` - CMD M662 Received. - Error: File not exists - ``` - args: - filename: string # Name of file (with or without .g/.gcode extension) - response: > - CMD M662 Received. - Error: File not exists - - OR - - CMD M662 Received. - ack header length: - - example: "~M662 calibration.g" - notes: > - - Extracts thumbnail from G-code header - - Returns PNG image data - - Falls back to default image if missing - - Used for UI thumbnails - - Binary response format with magic number - - M663: - summary: Get current position with extrusion flow - description: > - Query the current print head position with extrusion flow information. - - **Response Format:** - ``` - CMD M663 Received. - ok - ``` - - **Note:** - This command is similar to M114 but may provide enhanced position data. - Actual response format needs verification with live printer. - args: None - response: > - CMD M663 Received. - ok - example: "~M663" - notes: > - - Enhanced position query - - May include extrusion flow data - - Similar to M114 but with more details - -# Protocol Details -protocol: - port: 8899 - command_prefix: "~" - line_terminator: "\\r\\n" - response_prefix: "ack:" - max_connections: 10 - connection_timeout: 30 # seconds - recv_timeout: 5 # seconds - binary_mode: "M28/M29" # File upload uses binary mode - -# Build Volume -build_volume: - x: 150 # mm - y: 150 # mm - z: 150 # mm - origin: "front-left corner" # (0,0,0) - -# Temperature Limits -temperature_limits: - extruder_max: 265 # Celsius (configurable) - bed_max: 100 # Celsius (typical) - extruder_min: 0 # Celsius (room temp) - bed_min: 0 # Celsius (room temp) - -# Fan Specifications -fan: - type: "part cooling" - control: "on/off only" - speed_range: "fixed (100% when on)" - extruder_fan: "automatic (hotend temperature controlled)" - -# LED Support -led: - built_in: false - accessory_only: true - control: "on/off via M146" - internal_commands: "M144 (ON), M145 (OFF)" - -# Print File Support -file_formats: - supported: - - ".g" # G-code files - - ".gx" # FlashForge encrypted G-code - - ".gcode" # Standard G-code - storage_path: "/data" - upload_protocol: "binary M28/M29" - max_filesize: "4GB (FAT32 limit)" - -# Network Services -network: - tcp_port: 8899 - http_port: 8080 - http_purpose: "Camera streaming (MJPG)" - multicast: "Yes (M601/M602 control)" - authentication: "None (port 8899 unauthenticated)" - -# Firmware Information -firmware: - version: "v1.3.7 20230725" - machine_type: "FlashForge Adventurer III" - model_id: "X: 1.0 Y: 0.5" - serial_source: "Configuration file (SerialNoFile)" - mac_source: "/etc/MAC file" - -# Comparison with 5M/AD5X Series -differences_from_5m_series: - - "No HTTP REST API on port 8898 (TCP only control)" - - "No authentication required on port 8899" - - "Fan control is on/off only (no variable M106 S0-255)" - - "No built-in camera (accessory LED support only)" - - "Different set of custom M-codes (600/66x series)" - - "M108 command is stubbed out (non-functional)" - - "No material station or multi-color printing" - - "Simpler networking (M601/M602 only)" - - "No air filtration or TVOC monitoring" - - "Build volume: 150x150x150mm (smaller than newer models)" diff --git a/endpoints/networkserver_commands_adventurer4.yaml b/endpoints/networkserver_commands_adventurer4.yaml deleted file mode 100644 index 7fc7a6e..0000000 --- a/endpoints/networkserver_commands_adventurer4.yaml +++ /dev/null @@ -1,239 +0,0 @@ -# FlashForge Adventurer 4 Pro NetworkServer Protocol -# Firmware: 1.2.1-3.22 -# Port: 8899 (TCP) -# Protocol: TCP Socket with line-based ASCII commands (G-code/M-code) - -description: >- - This file documents the proprietary and standard G-code/M-code commands supported by the - NetworkServer component of the FlashForge Adventurer 4 Pro firmware. - These commands are sent over a TCP connection to port 8899. - - **Important**: All commands MUST be prefixed with a tilde character `~`. - Example: `~M115` instead of `M115`. - -commands: - # --- Motion Control --- - G1: - summary: Linear Move - description: >- - Move to position. The firmware checks for 'E' axis movement and may automatically - reset extruder position (G92 E0) before moving if printing via NetworkServer. - args: - X: float # X coordinate - Y: float # Y coordinate - Z: float # Z coordinate - E: float # Extruder coordinate - F: int # Feedrate - example: "~G1 X100 Y100 F3000" - - G28: - summary: Auto Home - description: Home all axes or specified axes. - args: - X: flag # Home X - Y: flag # Home Y - Z: flag # Home Z - example: "~G28 X Y" - - G90: - summary: Absolute Positioning - description: Set coordinates to absolute mode. - example: "~G90" - - G91: - summary: Relative Positioning - description: Set coordinates to relative mode. - example: "~G91" - - G92: - summary: Set Position - description: Set current position to specified values. - args: - X: float - Y: float - Z: float - E: float - example: "~G92 E0" - - M17: - summary: Enable Steppers - description: Enable stepper motors. - example: "~M17" - - M18: - summary: Disable Steppers - description: Disable stepper motors to allow manual movement. - example: "~M18" - - # --- File & Print Management --- - M23: - summary: Select File - description: >- - Select a file on the local storage for printing. - Paths are normalized (e.g., '0:/user/' -> '/data/'). - Checks file size before confirming. - args: - filename: string # Path to file (e.g., "cube.gcode") - example: "~M23 test_print.gx" - - M24: - summary: Start/Resume Print - description: Start or resume printing the selected file. - example: "~M24" - - M25: - summary: Pause Print - description: Pause the current print job. - example: "~M25" - - M26: - summary: Set SD Position - description: Set the file read position (seek). - example: "~M26" - - M27: - summary: Report Print Status - description: Get print progress/status. - example: "~M27" - - M28: - summary: Start File Upload - description: >- - Begin file upload to printer storage. - Format: ~M28 0:/user/ - - **Upload Flow:** - 1. Send `~M28 0:/user/` - 2. Server responds with `ack: M28 ...` - 3. Send exactly `` bytes of raw file data. - Do NOT prefix data with `~`. - 4. Server automatically detects end of file based on size. - 5. Send `~M29` to confirm and finalize. - args: - size: int # File size in bytes - path: string # Destination path (must start with 0:/user/) - example: "~M28 1024 0:/user/upload_test.gcode" - - M29: - summary: Stop File Upload - description: >- - Finalize file upload. Verifies the file size on disk matches the size declared in M28. - If sizes match, the file is kept. If not, it is deleted. - example: "~M29" - - M601: - summary: Pause Print (Internal) - description: Pauses print, stops timers, updates status. - example: "~M601" - - M602: - summary: Resume Print (Internal) - description: Resumes print from paused state. - example: "~M602" - - # --- Temperature & Environment --- - M104: - summary: Set Extruder Temperature - description: Set target temperature for the extruder. - args: - S: int # Temperature in Celsius - example: "~M104 S200" - - M105: - summary: Get Temperature - description: >- - Returns current temperatures. - Response format: "T0:/ B:/" - example: "~M105" - - M106: - summary: Fan On - description: Turn on the part cooling fan. - example: "~M106" - - M107: - summary: Fan Off - description: Turn off the part cooling fan. - example: "~M107" - - M108: - summary: Cancel Heat/Wait - description: Cancel heating wait loop (inferred). - example: "~M108" - - M140: - summary: Set Bed Temperature - description: Set target temperature for the heated bed. - args: - S: int # Temperature in Celsius - example: "~M140 S60" - - M146: - summary: LED Control - description: Control built-in LEDs. - args: - S: int # Brightness/State (e.g., S100 for on/max) - r: int # Red component (inferred) - g: int # Green component (inferred) - b: int # Blue component (inferred) - example: "~M146 r255 g0 b0" - - # --- Status & Info --- - M112: - summary: Emergency Stop - description: Immediate halt of all operations. - example: "~M112" - - M114: - summary: Get Current Position - description: Returns current axis coordinates. - example: "~M114" - - M115: - summary: Get Firmware Info - description: >- - Returns machine type, name, firmware version, serial number, - build volume dimensions (X/Y/Z), and tool count. - example: "~M115" - - M119: - summary: Get Endstop Status - description: Returns status of endstops, filament sensor, and machine state. - example: "~M119" - - # --- Unknown / Custom --- - M144: - summary: Unknown (Bed?) - description: Referenced in command handler map. - - M145: - summary: Material Preset? - description: Referenced in command handler map. - - M610: - summary: Unknown - description: Referenced in command handler map. - - M650: - summary: Unknown - description: Referenced in command handler map. - - M651: - summary: Unknown - description: Referenced in command handler map. - - M652: - summary: Unknown - description: Referenced in command handler map. - - M661: - summary: Unknown - description: Referenced in command handler map. - - M662: - summary: Unknown - description: Referenced in command handler map. - - M663: - summary: Unknown - description: Referenced in command handler map. \ No newline at end of file diff --git a/ha-addon/flashforge-dashboard/Dockerfile b/flashforge-dashboard/Dockerfile similarity index 100% rename from ha-addon/flashforge-dashboard/Dockerfile rename to flashforge-dashboard/Dockerfile diff --git a/ha-addon/flashforge-dashboard/build.yaml b/flashforge-dashboard/build.yaml similarity index 100% rename from ha-addon/flashforge-dashboard/build.yaml rename to flashforge-dashboard/build.yaml diff --git a/ha-addon/flashforge-dashboard/config.yaml b/flashforge-dashboard/config.yaml similarity index 100% rename from ha-addon/flashforge-dashboard/config.yaml rename to flashforge-dashboard/config.yaml diff --git a/ha-addon/flashforge-dashboard/frontend/public/app.js b/flashforge-dashboard/frontend/public/app.js similarity index 100% rename from ha-addon/flashforge-dashboard/frontend/public/app.js rename to flashforge-dashboard/frontend/public/app.js diff --git a/ha-addon/flashforge-dashboard/frontend/public/index.html b/flashforge-dashboard/frontend/public/index.html similarity index 100% rename from ha-addon/flashforge-dashboard/frontend/public/index.html rename to flashforge-dashboard/frontend/public/index.html diff --git a/ha-addon/flashforge-dashboard/frontend/public/style.css b/flashforge-dashboard/frontend/public/style.css similarity index 100% rename from ha-addon/flashforge-dashboard/frontend/public/style.css rename to flashforge-dashboard/frontend/public/style.css diff --git a/ha-addon/flashforge-dashboard/package.json b/flashforge-dashboard/package.json similarity index 100% rename from ha-addon/flashforge-dashboard/package.json rename to flashforge-dashboard/package.json diff --git a/ha-addon/flashforge-dashboard/run.sh b/flashforge-dashboard/run.sh similarity index 100% rename from ha-addon/flashforge-dashboard/run.sh rename to flashforge-dashboard/run.sh diff --git a/ha-addon/flashforge-dashboard/server.js b/flashforge-dashboard/server.js similarity index 100% rename from ha-addon/flashforge-dashboard/server.js rename to flashforge-dashboard/server.js diff --git a/ha-addon/README.md b/ha-addon/README.md deleted file mode 100644 index e45a427..0000000 --- a/ha-addon/README.md +++ /dev/null @@ -1,84 +0,0 @@ -# FlashForge Dashboard — Home Assistant Add-on - -Add-on per Home Assistant che porta la dashboard FlashForge direttamente nella sidebar di HA. -Controlli camera, temperatura, stampa e upload GCode integrati nell'interfaccia di Home Assistant. - -## Installazione - -### 1. Aggiungi il repository - -1. In Home Assistant vai su **Impostazioni → Add-on → Store** -2. Clicca **⋮** (tre puntini in alto a destra) → **Aggiungi repository** -3. Incolla l'URL del repository: - ``` - https://github.com/MikManenti/flashforge-api-docs - ``` -4. Clicca **Aggiungi** - -### 2. Installa l'add-on - -1. Cerca **FlashForge Dashboard** nella lista degli add-on -2. Clicca sull'add-on → **Installa** -3. Attendi il completamento del download e build del container - -### 3. Configura - -Nel tab **Configurazione** dell'add-on inserisci: - -| Campo | Descrizione | -|---|---| -| `printer_ip` | Indirizzo IP della stampante in LAN (es. `192.168.1.100`) | -| `serial_number` | Numero di serie della stampante (etichetta sul retro) | -| `check_code` | CheckCode LAN — vedi sotto come trovarlo | - -#### Come trovare il CheckCode - -1. Apri l'app **FlashForge** sul telefono -2. Seleziona la stampante → **Impostazioni** → **Connessione LAN** -3. Il codice a 8 cifre mostrato è il `check_code` - -### 4. Avvia - -1. Clicca **Avvia** nel tab Info dell'add-on -2. L'add-on appare nella **sidebar di Home Assistant** sotto il nome _FlashForge_ -3. Clicca sull'icona 🖨 per aprire la dashboard - -## Funzionalità - -| Funzione | Dettaglio | -|---|---| -| 🎥 Camera live | Stream MJPEG proxato, attivazione/disattivazione in-app | -| 🌡 Temperature | Ugello, piatto e camera — valori correnti e target | -| 📊 Info stampa | Layer corrente / totale, progresso, tempo rimanente, nome file | -| ⏸ ▶ ⏹ Controlli | Pausa, Riprendi, Stop della stampa corrente | -| 📂 File in memoria | Lista file sulla stampante, thumbnail, avvio stampa | -| ⬆ Upload GCode | Carica `.gcode` / `.g` / `.gx` / `.3mf` con opzione "Stampa subito" | - -## Note tecniche - -- L'add-on usa **Ingress**: è accessibile tramite il reverse proxy di HA senza aprire porte aggiuntive sul router, ed è protetto dall'autenticazione di Home Assistant. -- La porta interna del container è `8099`. -- La camera è accessibile su `http://:8080/?action=stream` (MJPEG, nessuna autenticazione richiesta dalla stampante). -- Architetture supportate: `amd64`, `aarch64`, `armv7`, `armhf`. - -## Struttura dell'add-on - -``` -ha-addon/ -├── repository.yaml ← Descrittore repository HA -└── flashforge-dashboard/ - ├── config.yaml ← Metadati, opzioni, ingress - ├── build.yaml ← Base image multi-arch - ├── Dockerfile - ├── run.sh ← Script avvio (legge config via bashio) - ├── server.js ← Backend Express - ├── package.json - └── frontend/public/ - ├── index.html - ├── style.css - └── app.js -``` - ---- - -*Progetto non ufficiale, non affiliato con FlashForge o Home Assistant.* diff --git a/ha-addon/repository.yaml b/repository.yaml similarity index 100% rename from ha-addon/repository.yaml rename to repository.yaml diff --git a/webapp/.env.example b/webapp/.env.example deleted file mode 100644 index 0e5be3b..0000000 --- a/webapp/.env.example +++ /dev/null @@ -1,16 +0,0 @@ -# FlashForge Web App — Configurazione -# Copia questo file in .env e compila i valori - -# Indirizzo IP della stampante in rete locale -PRINTER_IP=192.168.1.100 - -# Numero di serie della stampante (es. SN-XXXXXX) -# Reperibile sull'etichetta della stampante o nell'app FlashForge -SERIAL_NUMBER=your_serial_number_here - -# CheckCode per autenticazione API locale -# Reperibile nell'app FlashForge: Dispositivo → Impostazioni → Connessione LAN -CHECK_CODE=your_check_code_here - -# Porta su cui gira questa web app -PORT=3000 diff --git a/webapp/.gitignore b/webapp/.gitignore deleted file mode 100644 index bd6a1b7..0000000 --- a/webapp/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules/ -.env -uploads/ diff --git a/webapp/README.md b/webapp/README.md deleted file mode 100644 index 22066bd..0000000 --- a/webapp/README.md +++ /dev/null @@ -1,115 +0,0 @@ -# FlashForge Web App - -Web app locale per controllare la tua stampante FlashForge AD5M (o AD5X / 5M Pro) direttamente dal browser, senza cloud. - -## Funzionalità - -| Funzione | Dettaglio | -|---|---| -| 🎥 Camera live | Stream MJPEG proxato, attivazione/disattivazione in-app | -| 🌡 Temperature | Ugello, piatto e camera — valori correnti e target | -| 📊 Info stampa | Layer corrente / totale, progresso, tempo rimanente, nome file | -| ⏸ ▶ ⏹ Controlli | Pausa, Riprendi, Stop della stampa corrente | -| 📂 File in memoria | Lista file sulla stampante, anteprima thumbnail, avvio stampa | -| ⬆ Upload GCode | Carica `.gcode` / `.g` / `.gx` / `.3mf` con opzione "Stampa subito" | - -## Requisiti - -- [Node.js](https://nodejs.org/) 18 o superiore -- Stampante FlashForge sulla stessa rete locale - -## Installazione - -```bash -cd webapp -npm install -cp .env.example .env -``` - -Apri `.env` e inserisci: - -```env -PRINTER_IP=192.168.1.XXX # IP della stampante in LAN -SERIAL_NUMBER=SN-XXXXXXXX # Numero di serie (etichetta sulla stampante) -CHECK_CODE=XXXXXXXX # CheckCode LAN (vedi sotto) -PORT=3000 # Porta della web app (opzionale) -``` - -### Come trovare il CheckCode - -1. Apri l'app **FlashForge** sul tuo telefono -2. Seleziona la stampante → **Impostazioni** → **Connessione LAN** -3. Il codice a 8 cifre mostrato è il `checkCode` - -In alternativa puoi trovarlo ispezionando il traffico di rete con un proxy (es. Charles, mitmproxy) mentre l'app si connette alla stampante. - -## Avvio - -```bash -npm start -``` - -Apri il browser su **http://localhost:3000** - -Per lo sviluppo con auto-reload: - -```bash -npm run dev -``` - -## Struttura del progetto - -``` -webapp/ -├── server.js # Backend Express (proxy API FlashForge) -├── package.json -├── .env.example # Template configurazione -├── .gitignore -└── frontend/ - └── public/ - ├── index.html # UI principale - ├── style.css # Stili dark mode - └── app.js # Logica frontend (polling, upload, ecc.) -``` - -## API Backend esposte - -| Endpoint | Metodo | Descrizione | -|---|---|---| -| `/api/status` | GET | Stato completo della stampante | -| `/api/control` | POST | Pausa / Riprendi / Stop | -| `/api/files` | GET | Lista file in memoria stampante | -| `/api/thumb?fileName=` | GET | Thumbnail base64 di un file | -| `/api/print` | POST | Avvia stampa da file in memoria | -| `/api/upload` | POST | Upload GCode dal browser | -| `/api/camera/stream` | GET | Proxy stream MJPEG camera | -| `/api/camera` | POST | Attiva / disattiva camera | -| `/api/config` | GET | Verifica configurazione | - -## Docker (opzionale) - -```dockerfile -FROM node:20-alpine -WORKDIR /app -COPY package*.json ./ -RUN npm ci --omit=dev -COPY . . -EXPOSE 3000 -CMD ["node", "server.js"] -``` - -```bash -docker build -t flashforge-webapp . -docker run -d -p 3000:3000 --env-file .env flashforge-webapp -``` - -## Note - -- La stampante deve essere **sulla stessa rete locale** del server che esegue questa app. -- Il **CheckCode** autentica ogni richiesta HTTP alla porta `8898` della stampante; senza di esso le API restituiscono errore. -- Lo stream camera usa la porta `8080` della stampante (MJPEG over HTTP, senza autenticazione). -- Se la camera risulta spenta, usa il pulsante **"Attiva camera"** che invia prima il comando `streamCtrl_cmd` alla stampante. - ---- - -*Progetto non ufficiale, non affiliato con FlashForge.* diff --git a/webapp/frontend/public/app.js b/webapp/frontend/public/app.js deleted file mode 100644 index f8a77e7..0000000 --- a/webapp/frontend/public/app.js +++ /dev/null @@ -1,395 +0,0 @@ -'use strict'; - -/* ── State ───────────────────────────────────────────────────────────────── */ -let currentJobID = null; -let currentStatus = null; -let pollingTimer = null; -let cameraActive = false; - -/* ── DOM refs ────────────────────────────────────────────────────────────── */ -const badge = document.getElementById('status-badge'); -const cameraImg = document.getElementById('camera-img'); -const cameraPlaceholder = document.getElementById('camera-placeholder'); -const btnCameraOn = document.getElementById('btn-camera-on'); -const btnCameraOff = document.getElementById('btn-camera-off'); -const sFname = document.getElementById('s-filename'); -const sProgress = document.getElementById('s-progress'); -const sLayer = document.getElementById('s-layer'); -const sTime = document.getElementById('s-time'); -const progressBar = document.getElementById('progress-bar'); -const tNozzle = document.getElementById('t-nozzle'); -const tNozzleTarget = document.getElementById('t-nozzle-target'); -const tBed = document.getElementById('t-bed'); -const tBedTarget = document.getElementById('t-bed-target'); -const tChamber = document.getElementById('t-chamber'); -const tChamberTarget = document.getElementById('t-chamber-target'); -const btnPause = document.getElementById('btn-pause'); -const btnResume = document.getElementById('btn-resume'); -const btnStop = document.getElementById('btn-stop'); -const ctrlMsg = document.getElementById('ctrl-message'); -const lastUpdate = document.getElementById('last-update'); - -const btnRefreshFiles = document.getElementById('btn-refresh-files'); -const fileList = document.getElementById('file-list'); -const printModal = document.getElementById('print-modal'); -const modalFilename = document.getElementById('modal-filename'); -const modalLeveling = document.getElementById('modal-leveling'); -const modalConfirm = document.getElementById('modal-confirm'); -const modalCancel = document.getElementById('modal-cancel'); - -const uploadForm = document.getElementById('upload-form'); -const fileInput = document.getElementById('file-input'); -const dropText = document.getElementById('drop-text'); -const dropArea = document.getElementById('drop-area'); -const printNowChk = document.getElementById('print-now'); -const levelingUpload = document.getElementById('leveling-upload'); -const btnUpload = document.getElementById('btn-upload'); -const uploadProgressWrap = document.getElementById('upload-progress'); -const uploadProgressBar = document.getElementById('upload-progress-bar'); -const uploadProgressText = document.getElementById('upload-progress-text'); -const uploadMessage = document.getElementById('upload-message'); - -/* ── Utilities ───────────────────────────────────────────────────────────── */ -function fmt(v, unit = '°C') { - return v !== undefined && v !== null ? `${Math.round(v)}${unit}` : '—'; -} -function fmtTime(seconds) { - if (!seconds || seconds < 0) return '—'; - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = Math.floor(seconds % 60); - if (h > 0) return `${h}h ${m}m`; - if (m > 0) return `${m}m ${s}s`; - return `${s}s`; -} -function showCtrlMsg(msg, ok = true) { - ctrlMsg.textContent = msg; - ctrlMsg.style.color = ok ? 'var(--success)' : 'var(--danger)'; - setTimeout(() => { ctrlMsg.textContent = ''; }, 4000); -} -function showUploadMsg(msg, ok = true) { - uploadMessage.textContent = msg; - uploadMessage.style.color = ok ? 'var(--success)' : 'var(--danger)'; -} - -/* ── Status polling ──────────────────────────────────────────────────────── */ -async function fetchStatus() { - try { - const res = await fetch('/api/status'); - const json = await res.json(); - if (json.detail) updateUI(json.detail); - lastUpdate.textContent = `Ultimo aggiornamento: ${new Date().toLocaleTimeString('it-IT')}`; - } catch (e) { - badge.textContent = 'Errore connessione'; - badge.className = 'badge badge--error'; - } -} - -function updateUI(d) { - currentStatus = d.status || 'IDLE'; - - // Badge - const statusMap = { - PRINTING: ['badge--printing', 'STAMPA'], - PAUSED: ['badge--paused', 'IN PAUSA'], - IDLE: ['badge--idle', 'INATTIVA'], - ERROR: ['badge--error', 'ERRORE'], - HOMING: ['badge--printing', 'HOMING'], - }; - const [cls, label] = statusMap[currentStatus] || ['badge--idle', currentStatus]; - badge.className = `badge ${cls}`; - badge.textContent = label; - - // Job info - currentJobID = d.jobInfo ? (d.jobInfo[0] && d.jobInfo[0][1]) : null; - sFname.textContent = d.printFileName || '—'; - sFname.title = d.printFileName || ''; - - const pct = d.printProgress != null ? Math.round(d.printProgress * 100) : null; - sProgress.textContent = pct !== null ? `${pct}%` : '—'; - progressBar.style.width = pct !== null ? `${pct}%` : '0%'; - - const layer = d.printLayer ?? null; - const maxLayer = d.targetPrintLayer ?? null; - sLayer.textContent = (layer !== null && maxLayer !== null) - ? `${layer} / ${maxLayer}` - : (layer !== null ? String(layer) : '—'); - - sTime.textContent = fmtTime(d.estimatedTime); - - // Temperatures - tNozzle.textContent = fmt(d.rightTemp); - tNozzleTarget.textContent = d.rightTargetTemp ? `→ ${fmt(d.rightTargetTemp)}` : ''; - tBed.textContent = fmt(d.platTemp); - tBedTarget.textContent = d.platTargetTemp ? `→ ${fmt(d.platTargetTemp)}` : ''; - tChamber.textContent = fmt(d.chamberTemp); - tChamberTarget.textContent = d.chamberTargetTemp ? `→ ${fmt(d.chamberTargetTemp)}` : ''; - - // Controls enable/disable - const isPrinting = currentStatus === 'PRINTING'; - const isPaused = currentStatus === 'PAUSED'; - btnPause.disabled = !isPrinting; - btnResume.disabled = !isPaused; - btnStop.disabled = !(isPrinting || isPaused); - - // Camera: if the printer reports a stream URL, auto-enable - if (d.cameraStreamUrl && !cameraActive) { - enableCamera(); - } -} - -/* ── Camera ──────────────────────────────────────────────────────────────── */ -function enableCamera() { - cameraImg.src = `/api/camera/stream?t=${Date.now()}`; - cameraImg.classList.add('active'); - cameraPlaceholder.classList.add('hidden'); - cameraActive = true; -} -function disableCamera() { - cameraImg.src = ''; - cameraImg.classList.remove('active'); - cameraPlaceholder.classList.remove('hidden'); - cameraActive = false; -} - -btnCameraOn.addEventListener('click', async () => { - try { - await fetch('/api/camera', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'open' }) }); - } catch (_) { /* ignore – try to show stream anyway */ } - enableCamera(); -}); - -btnCameraOff.addEventListener('click', async () => { - disableCamera(); - try { - await fetch('/api/camera', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'close' }) }); - } catch (_) { /* ignore */ } -}); - -cameraImg.addEventListener('error', () => { - disableCamera(); -}); - -/* ── Print controls ──────────────────────────────────────────────────────── */ -async function sendControl(action) { - if (!currentJobID) { showCtrlMsg('Nessun job attivo.', false); return; } - try { - const res = await fetch('/api/control', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action, jobID: currentJobID }), - }); - const json = await res.json(); - if (json.code === 0) { - showCtrlMsg(`Comando "${action}" inviato.`); - await fetchStatus(); - } else { - showCtrlMsg(`Errore: ${json.message || json.code}`, false); - } - } catch (e) { - showCtrlMsg(`Errore di rete: ${e.message}`, false); - } -} - -btnPause.addEventListener('click', () => sendControl('pause')); -btnResume.addEventListener('click', () => sendControl('resume')); -btnStop.addEventListener('click', async () => { - if (!confirm('Sei sicuro di voler interrompere la stampa?')) return; - await sendControl('stop'); -}); - -/* ── File list ───────────────────────────────────────────────────────────── */ -let selectedFileName = null; - -btnRefreshFiles.addEventListener('click', loadFiles); - -async function loadFiles() { - fileList.innerHTML = '

Caricamento…

'; - try { - const res = await fetch('/api/files'); - const json = await res.json(); - const files = json.gcodeList || []; - if (!files.length) { - fileList.innerHTML = '

Nessun file trovato nella stampante.

'; - return; - } - fileList.innerHTML = ''; - files.forEach(renderFileItem); - } catch (e) { - fileList.innerHTML = `

Errore: ${e.message}

`; - } -} - -async function renderFileItem(fileName) { - const item = document.createElement('div'); - item.className = 'file-item'; - - // Thumb - const thumbWrap = document.createElement('div'); - thumbWrap.className = 'file-thumb-placeholder'; - thumbWrap.textContent = '📄'; - item.appendChild(thumbWrap); - - const nameEl = document.createElement('span'); - nameEl.className = 'file-name'; - nameEl.textContent = fileName; - item.appendChild(nameEl); - - item.addEventListener('click', () => openPrintModal(fileName)); - fileList.appendChild(item); - - // Async thumb load - try { - const res = await fetch(`/api/thumb?fileName=${encodeURIComponent(fileName)}`); - const json = await res.json(); - if (json.imageData) { - const img = document.createElement('img'); - img.className = 'file-thumb'; - img.src = `data:image/png;base64,${json.imageData}`; - img.alt = fileName; - thumbWrap.replaceWith(img); - } - } catch (_) { /* keep placeholder */ } -} - -function openPrintModal(fileName) { - selectedFileName = fileName; - modalFilename.textContent = fileName; - modalLeveling.checked = false; - printModal.classList.remove('hidden'); -} - -modalCancel.addEventListener('click', () => { - printModal.classList.add('hidden'); - selectedFileName = null; -}); - -modalConfirm.addEventListener('click', async () => { - if (!selectedFileName) return; - printModal.classList.add('hidden'); - try { - const res = await fetch('/api/print', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ fileName: selectedFileName, levelingBeforePrint: modalLeveling.checked }), - }); - const json = await res.json(); - if (json.code === 0) { - showCtrlMsg(`Stampa avviata: ${selectedFileName}`); - await fetchStatus(); - } else { - showCtrlMsg(`Errore: ${json.message || json.code}`, false); - } - } catch (e) { - showCtrlMsg(`Errore di rete: ${e.message}`, false); - } - selectedFileName = null; -}); - -// Close modal on backdrop click -printModal.addEventListener('click', (e) => { - if (e.target === printModal) { - printModal.classList.add('hidden'); - selectedFileName = null; - } -}); - -/* ── Upload ──────────────────────────────────────────────────────────────── */ -fileInput.addEventListener('change', () => { - if (fileInput.files[0]) { - dropText.textContent = `📄 ${fileInput.files[0].name}`; - btnUpload.disabled = false; - } -}); - -// Drag & drop -dropArea.addEventListener('dragover', (e) => { e.preventDefault(); dropArea.classList.add('drag-over'); }); -dropArea.addEventListener('dragleave', () => dropArea.classList.remove('drag-over')); -dropArea.addEventListener('drop', (e) => { - e.preventDefault(); - dropArea.classList.remove('drag-over'); - const file = e.dataTransfer.files[0]; - if (file) { - const dt = new DataTransfer(); - dt.items.add(file); - fileInput.files = dt.files; - dropText.textContent = `📄 ${file.name}`; - btnUpload.disabled = false; - } -}); - -uploadForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const file = fileInput.files[0]; - if (!file) return; - - btnUpload.disabled = true; - uploadProgressWrap.classList.remove('hidden'); - setUploadPct(0); - showUploadMsg(''); - - const formData = new FormData(); - formData.append('gcodeFile', file); - formData.append('printNow', printNowChk.checked ? '1' : '0'); - formData.append('levelingBeforePrint', levelingUpload.checked ? '1' : '0'); - - // Use XMLHttpRequest for upload progress - const xhr = new XMLHttpRequest(); - xhr.open('POST', '/api/upload'); - - xhr.upload.addEventListener('progress', (ev) => { - if (ev.lengthComputable) setUploadPct(Math.round((ev.loaded / ev.total) * 100)); - }); - - xhr.addEventListener('load', () => { - try { - const json = JSON.parse(xhr.responseText); - if (json.code === 0) { - showUploadMsg(`✅ Upload completato: ${file.name}`); - if (printNowChk.checked) fetchStatus(); - loadFiles(); - } else { - showUploadMsg(`❌ Errore stampante: ${json.message || json.code}`, false); - } - } catch (_) { - showUploadMsg(`❌ Risposta non valida (HTTP ${xhr.status})`, false); - } - btnUpload.disabled = false; - uploadProgressWrap.classList.add('hidden'); - }); - - xhr.addEventListener('error', () => { - showUploadMsg('❌ Errore di rete durante l\'upload.', false); - btnUpload.disabled = false; - uploadProgressWrap.classList.add('hidden'); - }); - - xhr.send(formData); -}); - -function setUploadPct(pct) { - uploadProgressBar.style.setProperty('--pct', `${pct}%`); - uploadProgressText.textContent = `${pct}%`; -} - -/* ── Boot ────────────────────────────────────────────────────────────────── */ -(async () => { - // Check configuration - try { - const cfg = await fetch('/api/config').then(r => r.json()); - if (!cfg.configured) { - badge.textContent = 'NON CONFIGURATO'; - badge.className = 'badge badge--error'; - document.querySelector('main').insertAdjacentHTML('afterbegin', - `
- ⚠️ La stampante non è configurata. Crea il file .env nella cartella webapp/ - copiando .env.example e inserendo IP, numero di serie e CheckCode. -
` - ); - return; - } - } catch (_) { /* proceed anyway */ } - - await fetchStatus(); - pollingTimer = setInterval(fetchStatus, 4000); -})(); diff --git a/webapp/frontend/public/index.html b/webapp/frontend/public/index.html deleted file mode 100644 index d673bc9..0000000 --- a/webapp/frontend/public/index.html +++ /dev/null @@ -1,150 +0,0 @@ - - - - - - FlashForge Dashboard - - - -
-
-

🖨 FlashForge Dashboard

-
-
-
- -
- - -
-

Camera

-
- Camera stream -
- Camera non disponibile -
-
-
- - -
-
- - -
-

Stato stampa

- -
-
- File - -
-
- Progresso - -
-
- Layer - -
-
- Tempo rimanente - -
-
- -
-
-
- -
-
- 🔥 Ugello - - -
-
- 🛏 Piatto - - -
-
- 📦 Camera - - -
-
- - - -
-
- - -
-

File in memoria

-
- -
-
-

Premi "Aggiorna lista" per caricare i file.

-
- - - -
- - -
-

Carica GCode

-
-
- - -
-
- - -
- -
- -
-
- -
- -
- Non ancora aggiornato - Refresh automatico ogni 4s -
- - - - diff --git a/webapp/frontend/public/style.css b/webapp/frontend/public/style.css deleted file mode 100644 index 56dfc70..0000000 --- a/webapp/frontend/public/style.css +++ /dev/null @@ -1,345 +0,0 @@ -/* ── Reset & base ─────────────────────────────────────────────────────── */ -*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } - -:root { - --bg: #0f1117; - --surface: #1a1d27; - --surface2: #222538; - --border: #2e3150; - --text: #e4e6f1; - --text-muted: #7a7f9a; - --accent: #4f8ef7; - --success: #3ecf74; - --warning: #f0a500; - --danger: #e04c4c; - --radius: 10px; - --shadow: 0 4px 24px rgba(0,0,0,.35); - --transition: .2s ease; -} - -body { - background: var(--bg); - color: var(--text); - font-family: 'Segoe UI', system-ui, sans-serif; - font-size: 14px; - min-height: 100vh; -} - -/* ── Header ─────────────────────────────────────────────────────────────── */ -header { - background: var(--surface); - border-bottom: 1px solid var(--border); - padding: 0 24px; - height: 56px; - display: flex; - align-items: center; -} -.header-inner { - width: 100%; - max-width: 1400px; - margin: 0 auto; - display: flex; - align-items: center; - justify-content: space-between; -} -header h1 { font-size: 18px; font-weight: 600; letter-spacing: .5px; } - -/* ── Badge ───────────────────────────────────────────────────────────────── */ -.badge { - padding: 4px 14px; - border-radius: 20px; - font-size: 12px; - font-weight: 700; - letter-spacing: .6px; - text-transform: uppercase; -} -.badge--idle { background: rgba(122,127,154,.2); color: var(--text-muted); } -.badge--printing { background: rgba(79,142,247,.2); color: var(--accent); } -.badge--paused { background: rgba(240,165,0,.2); color: var(--warning); } -.badge--error { background: rgba(224,76,76,.2); color: var(--danger); } - -/* ── Main grid ──────────────────────────────────────────────────────────── */ -main { - max-width: 1400px; - margin: 24px auto; - padding: 0 24px; - display: grid; - grid-template-columns: 1fr 1fr; - grid-template-rows: auto auto; - gap: 20px; -} - -@media (max-width: 860px) { - main { grid-template-columns: 1fr; } -} - -/* ── Card ────────────────────────────────────────────────────────────────── */ -.card { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 20px; - box-shadow: var(--shadow); -} -.card h2 { - font-size: 13px; - text-transform: uppercase; - letter-spacing: 1px; - color: var(--text-muted); - margin-bottom: 16px; -} - -/* ── Camera ─────────────────────────────────────────────────────────────── */ -.card--camera { grid-column: 1; } - -.camera-wrap { - width: 100%; - aspect-ratio: 16/9; - background: #000; - border-radius: 6px; - overflow: hidden; - position: relative; - margin-bottom: 12px; -} -#camera-img { - width: 100%; - height: 100%; - object-fit: contain; - display: none; -} -#camera-img.active { display: block; } -.camera-placeholder { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - color: var(--text-muted); - font-size: 13px; -} -.camera-placeholder.hidden { display: none; } -.camera-controls { display: flex; gap: 10px; } - -/* ── Stat grid ──────────────────────────────────────────────────────────── */ -.card--status { grid-column: 2; } - -.stat-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; - margin-bottom: 16px; -} -.stat { - background: var(--surface2); - border-radius: 8px; - padding: 12px 14px; -} -.stat-label { - display: block; - font-size: 11px; - text-transform: uppercase; - letter-spacing: .6px; - color: var(--text-muted); - margin-bottom: 4px; -} -.stat-value { - display: block; - font-size: 18px; - font-weight: 600; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* ── Progress bar ────────────────────────────────────────────────────────── */ -.progress-bar-wrap { - background: var(--surface2); - border-radius: 4px; - height: 8px; - overflow: hidden; - margin-bottom: 16px; -} -.progress-bar { - height: 100%; - width: 0%; - background: var(--accent); - border-radius: 4px; - transition: width .5s ease; -} - -/* ── Temp cards ─────────────────────────────────────────────────────────── */ -.temp-grid { - display: flex; - gap: 12px; - margin-bottom: 16px; -} -.temp-card { - flex: 1; - background: var(--surface2); - border-radius: 8px; - padding: 12px; - display: flex; - flex-direction: column; - gap: 4px; -} -.temp-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .5px; } -.temp-value { font-size: 22px; font-weight: 700; } -.temp-target { font-size: 11px; color: var(--text-muted); } - -/* ── Print controls ─────────────────────────────────────────────────────── */ -.print-controls { display: flex; gap: 10px; flex-wrap: wrap; } -.ctrl-message { margin-top: 10px; font-size: 12px; color: var(--text-muted); min-height: 16px; } - -/* ── Buttons ─────────────────────────────────────────────────────────────── */ -.btn { - padding: 8px 18px; - border: none; - border-radius: 6px; - font-size: 13px; - font-weight: 600; - cursor: pointer; - transition: opacity var(--transition), transform var(--transition); -} -.btn:hover:not(:disabled) { opacity: .85; transform: translateY(-1px); } -.btn:active:not(:disabled) { transform: translateY(0); } -.btn:disabled { opacity: .35; cursor: not-allowed; } - -.btn--primary { background: var(--accent); color: #fff; } -.btn--secondary { background: var(--surface2); color: var(--text); border: 1px solid var(--border); } -.btn--success { background: var(--success); color: #000; } -.btn--warning { background: var(--warning); color: #000; } -.btn--danger { background: var(--danger); color: #fff; } - -/* ── File list ──────────────────────────────────────────────────────────── */ -.card--files { grid-column: 1; } - -.files-toolbar { margin-bottom: 12px; } - -.file-list { - max-height: 320px; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 8px; -} -.file-item { - background: var(--surface2); - border: 1px solid var(--border); - border-radius: 8px; - padding: 10px 14px; - display: flex; - align-items: center; - gap: 12px; - cursor: pointer; - transition: background var(--transition); -} -.file-item:hover { background: #2a2e45; } -.file-thumb { - width: 44px; - height: 44px; - object-fit: contain; - border-radius: 4px; - background: #000; - flex-shrink: 0; -} -.file-thumb-placeholder { - width: 44px; - height: 44px; - background: var(--surface); - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - font-size: 20px; - flex-shrink: 0; -} -.file-name { flex: 1; font-size: 13px; word-break: break-all; } -.hint { color: var(--text-muted); font-size: 12px; } - -/* ── Modal ───────────────────────────────────────────────────────────────── */ -.modal { - position: fixed; - inset: 0; - background: rgba(0,0,0,.65); - display: flex; - align-items: center; - justify-content: center; - z-index: 100; -} -.modal.hidden { display: none; } -.modal-box { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 28px; - width: 360px; - box-shadow: var(--shadow); -} -.modal-box h3 { margin-bottom: 12px; } -.modal-box p { margin-bottom: 16px; color: var(--text-muted); font-size: 13px; } -.modal-actions { display: flex; gap: 10px; margin-top: 18px; } - -/* ── Upload ─────────────────────────────────────────────────────────────── */ -.card--upload { grid-column: 2; } - -.file-drop-area { - border: 2px dashed var(--border); - border-radius: 8px; - padding: 28px 16px; - text-align: center; - cursor: pointer; - transition: border-color var(--transition), background var(--transition); - margin-bottom: 14px; -} -.file-drop-area.drag-over { - border-color: var(--accent); - background: rgba(79,142,247,.07); -} -.file-drop-area input[type="file"] { display: none; } -.file-drop-label { cursor: pointer; color: var(--text-muted); font-size: 13px; } -.file-drop-label span { display: block; } - -.upload-options { display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px; } -.checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; } -.checkbox-label input { accent-color: var(--accent); } - -.upload-progress { - margin-top: 10px; - display: flex; - align-items: center; - gap: 10px; -} -.upload-progress.hidden { display: none; } -.upload-progress-bar { - flex: 1; - height: 6px; - background: var(--surface2); - border-radius: 4px; - overflow: hidden; - position: relative; -} -.upload-progress-bar::after { - content: ''; - position: absolute; - left: 0; top: 0; bottom: 0; - width: var(--pct, 0%); - background: var(--accent); - transition: width .3s ease; -} - -/* ── Footer ─────────────────────────────────────────────────────────────── */ -footer { - border-top: 1px solid var(--border); - padding: 12px 24px; - display: flex; - justify-content: space-between; - font-size: 11px; - color: var(--text-muted); - max-width: 1400px; - margin: 0 auto; -} - -/* ── Scrollbar ───────────────────────────────────────────────────────────── */ -::-webkit-scrollbar { width: 6px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } diff --git a/webapp/package-lock.json b/webapp/package-lock.json deleted file mode 100644 index dc57721..0000000 --- a/webapp/package-lock.json +++ /dev/null @@ -1,1431 +0,0 @@ -{ - "name": "flashforge-webapp", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "flashforge-webapp", - "version": "1.0.0", - "dependencies": { - "dotenv": "^16.4.5", - "express": "^4.19.2", - "multer": "^1.4.5-lts.1", - "node-fetch": "^2.7.0" - }, - "devDependencies": { - "nodemon": "^3.1.4" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/append-field": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", - "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", - "license": "MIT" - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" - }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", - "engines": [ - "node >= 0.8" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^2.2.2", - "typedarray": "^0.0.6" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.15.1", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ignore-by-default": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", - "dev": true, - "license": "ISC" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/multer": { - "version": "1.4.5-lts.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", - "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", - "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", - "license": "MIT", - "dependencies": { - "append-field": "^1.0.0", - "busboy": "^1.0.0", - "concat-stream": "^1.5.2", - "mkdirp": "^0.5.4", - "object-assign": "^4.1.1", - "type-is": "^1.6.4", - "xtend": "^4.0.0" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/nodemon": { - "version": "3.1.14", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", - "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.2", - "debug": "^4", - "ignore-by-default": "^1.0.1", - "minimatch": "^10.2.1", - "pstree.remy": "^1.1.8", - "semver": "^7.5.3", - "simple-update-notifier": "^2.0.0", - "supports-color": "^5.5.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.5" - }, - "bin": { - "nodemon": "bin/nodemon.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nodemon" - } - }, - "node_modules/nodemon/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/nodemon/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==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" - }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/pstree.remy": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", - "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", - "dev": true, - "license": "MIT" - }, - "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "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/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", - "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/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/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/touch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", - "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", - "dev": true, - "license": "ISC", - "bin": { - "nodetouch": "bin/nodetouch.js" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT" - }, - "node_modules/undefsafe": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", - "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", - "dev": true, - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - } - } -} diff --git a/webapp/package.json b/webapp/package.json deleted file mode 100644 index fe785f7..0000000 --- a/webapp/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "flashforge-webapp", - "version": "1.0.0", - "description": "Local web UI for FlashForge AD5M / AD5X printers", - "main": "server.js", - "scripts": { - "start": "node server.js", - "dev": "nodemon server.js" - }, - "dependencies": { - "dotenv": "^16.4.5", - "express": "^4.19.2", - "multer": "^1.4.5-lts.1", - "node-fetch": "^2.7.0" - }, - "devDependencies": { - "nodemon": "^3.1.4" - } -} diff --git a/webapp/server.js b/webapp/server.js deleted file mode 100644 index 895b4fd..0000000 --- a/webapp/server.js +++ /dev/null @@ -1,257 +0,0 @@ -'use strict'; - -require('dotenv').config(); -const express = require('express'); -const multer = require('multer'); -const fetch = require('node-fetch'); -const fs = require('fs'); -const path = require('path'); -const http = require('http'); - -const app = express(); -const PORT = process.env.PORT || 3000; -const PRINTER_IP = process.env.PRINTER_IP; -const SERIAL_NUMBER = process.env.SERIAL_NUMBER; -const CHECK_CODE = process.env.CHECK_CODE; -const PRINTER_API = `http://${PRINTER_IP}:8898`; -const CAMERA_URL = `http://${PRINTER_IP}:8080/?action=stream`; - -// ── Middleware ────────────────────────────────────────────────────────────── -app.use(express.json()); -app.use(express.static(path.join(__dirname, 'frontend', 'public'))); - -// multer: store upload in memory, then stream to printer -const upload = multer({ storage: multer.memoryStorage() }); - -// ── Helpers ───────────────────────────────────────────────────────────────── - -/** - * POST to the printer's HTTP REST API with standard auth fields. - */ -async function printerPost(endpoint, body = {}) { - const payload = { serialNumber: SERIAL_NUMBER, checkCode: CHECK_CODE, ...body }; - const res = await fetch(`${PRINTER_API}${endpoint}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`Printer returned ${res.status}: ${text}`); - } - return res.json(); -} - -/** - * Validate that required env vars are set and return 503 otherwise. - */ -function requireConfig(req, res, next) { - if (!PRINTER_IP || !SERIAL_NUMBER || !CHECK_CODE) { - return res.status(503).json({ - error: 'Printer not configured. Copy .env.example to .env and fill in PRINTER_IP, SERIAL_NUMBER, CHECK_CODE.', - }); - } - next(); -} - -// ── API Routes ─────────────────────────────────────────────────────────────── - -/** - * GET /api/status - * Returns the full detail response from the printer. - */ -app.get('/api/status', requireConfig, async (req, res) => { - try { - const data = await printerPost('/detail'); - res.json(data); - } catch (err) { - res.status(502).json({ error: err.message }); - } -}); - -/** - * POST /api/control - * Body: { action: "pause"|"resume"|"stop", jobID: "..." } - */ -app.post('/api/control', requireConfig, async (req, res) => { - const { action, jobID } = req.body; - if (!action || !jobID) { - return res.status(400).json({ error: 'action and jobID are required' }); - } - try { - const data = await printerPost('/control', { - payload: { - cmd: 'jobCtl_cmd', - args: { jobID, action }, - }, - }); - res.json(data); - } catch (err) { - res.status(502).json({ error: err.message }); - } -}); - -/** - * GET /api/files - * Returns the list of printable files stored on the printer. - */ -app.get('/api/files', requireConfig, async (req, res) => { - try { - const data = await printerPost('/gcodeList'); - res.json(data); - } catch (err) { - res.status(502).json({ error: err.message }); - } -}); - -/** - * GET /api/thumb?fileName=... - * Returns base64 thumbnail for a file. - */ -app.get('/api/thumb', requireConfig, async (req, res) => { - const { fileName } = req.query; - if (!fileName) return res.status(400).json({ error: 'fileName is required' }); - try { - const data = await printerPost('/gcodeThumb', { fileName }); - res.json(data); - } catch (err) { - res.status(502).json({ error: err.message }); - } -}); - -/** - * POST /api/print - * Body: { fileName: "...", levelingBeforePrint: true|false } - * Starts printing a file already stored on the printer. - */ -app.post('/api/print', requireConfig, async (req, res) => { - const { fileName, levelingBeforePrint = false } = req.body; - if (!fileName) return res.status(400).json({ error: 'fileName is required' }); - try { - const data = await printerPost('/printGcode', { fileName, levelingBeforePrint }); - res.json(data); - } catch (err) { - res.status(502).json({ error: err.message }); - } -}); - -/** - * POST /api/upload - * Multipart form with field "gcodeFile". - * Optional form fields: printNow (0|1), levelingBeforePrint (0|1) - */ -app.post('/api/upload', requireConfig, upload.single('gcodeFile'), async (req, res) => { - if (!req.file) return res.status(400).json({ error: 'gcodeFile is required' }); - - const printNow = req.body.printNow || '0'; - const levelingBeforePrint = req.body.levelingBeforePrint || '0'; - const fileSize = req.file.size; - - // Build a multipart body to forward to the printer - const boundary = `----FormBoundary${Date.now()}`; - const preamble = [ - `--${boundary}`, - `Content-Disposition: form-data; name="gcodeFile"; filename="${req.file.originalname}"`, - `Content-Type: application/octet-stream`, - '', - '', - ].join('\r\n'); - const epilogue = `\r\n--${boundary}--\r\n`; - - const body = Buffer.concat([ - Buffer.from(preamble), - req.file.buffer, - Buffer.from(epilogue), - ]); - - const printerRes = await fetch(`${PRINTER_API}/uploadGcode`, { - method: 'POST', - headers: { - 'Content-Type': `multipart/form-data; boundary=${boundary}`, - serialNumber: SERIAL_NUMBER, - checkCode: CHECK_CODE, - fileSize: String(fileSize), - printNow, - levelingBeforePrint, - }, - body, - }); - - const result = await printerRes.json().catch(() => ({ code: printerRes.status })); - res.status(printerRes.ok ? 200 : 502).json(result); -}); - -/** - * GET /api/camera/stream - * Proxies the MJPEG stream from the printer camera so the browser - * can display it without cross-origin issues. - */ -app.get('/api/camera/stream', requireConfig, (req, res) => { - const url = new URL(CAMERA_URL); - const options = { - hostname: url.hostname, - port: url.port || 8080, - path: url.pathname + url.search, - method: 'GET', - }; - - const proxyReq = http.request(options, (proxyRes) => { - res.writeHead(proxyRes.statusCode, { - 'Content-Type': proxyRes.headers['content-type'] || 'multipart/x-mixed-replace', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }); - proxyRes.pipe(res); - }); - - proxyReq.on('error', (err) => { - if (!res.headersSent) { - res.status(502).json({ error: `Camera stream error: ${err.message}` }); - } - }); - - req.on('close', () => proxyReq.destroy()); - proxyReq.end(); -}); - -/** - * POST /api/camera - * Body: { action: "open"|"close" } - * Enables or disables the camera stream on the printer. - */ -app.post('/api/camera', requireConfig, async (req, res) => { - const { action } = req.body; - if (!action) return res.status(400).json({ error: 'action is required' }); - try { - const data = await printerPost('/control', { - payload: { - cmd: 'streamCtrl_cmd', - args: { action }, - }, - }); - res.json(data); - } catch (err) { - res.status(502).json({ error: err.message }); - } -}); - -// ── Config check endpoint ──────────────────────────────────────────────────── -app.get('/api/config', (req, res) => { - res.json({ - configured: !!(PRINTER_IP && SERIAL_NUMBER && CHECK_CODE), - printerIp: PRINTER_IP || null, - cameraUrl: PRINTER_IP ? CAMERA_URL : null, - }); -}); - -// ── Serve frontend for all other routes ───────────────────────────────────── -app.get('*', (req, res) => { - res.sendFile(path.join(__dirname, 'frontend', 'public', 'index.html')); -}); - -app.listen(PORT, () => { - console.log(`FlashForge Web App running at http://localhost:${PORT}`); - if (!PRINTER_IP || !SERIAL_NUMBER || !CHECK_CODE) { - console.warn('⚠ PRINTER_IP, SERIAL_NUMBER or CHECK_CODE not set. Copy .env.example → .env and configure them.'); - } -}); From 8ad89cc0f266ceefbe8de78b42fdb10482385602 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:35:41 +0000 Subject: [PATCH 04/70] fix: avoid filesystem read in wildcard route handler --- flashforge-dashboard/server.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index fef1f1d..cebdfe9 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -258,12 +258,9 @@ app.get('/api/config', (req, res) => { // absolute paths (e.g. /api/status) which would bypass the ingress prefix. // We inject window.INGRESS_PATH into the HTML so the frontend can prefix them. const INDEX_HTML_PATH = path.join(__dirname, 'frontend', 'public', 'index.html'); -let indexHtmlBase = null; +const indexHtmlBase = fs.readFileSync(INDEX_HTML_PATH, 'utf8'); function serveIndex(req, res) { - if (!indexHtmlBase) { - indexHtmlBase = fs.readFileSync(INDEX_HTML_PATH, 'utf8'); - } const script = `\n`; const html = indexHtmlBase.replace('', ` ${script}`); res.setHeader('Content-Type', 'text/html; charset=utf-8'); From 0bbbf4dd24b1e7d9eabf32545e2b9cd27fe8fe14 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:43:29 +0000 Subject: [PATCH 05/70] chore: update base image from nodejs:20 to nodejs:22 --- flashforge-dashboard/build.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flashforge-dashboard/build.yaml b/flashforge-dashboard/build.yaml index e86ee03..6d1a8ff 100644 --- a/flashforge-dashboard/build.yaml +++ b/flashforge-dashboard/build.yaml @@ -1,7 +1,7 @@ build_from: - amd64: "ghcr.io/home-assistant/amd64-base-nodejs:20" - aarch64: "ghcr.io/home-assistant/aarch64-base-nodejs:20" - armv7: "ghcr.io/home-assistant/armv7-base-nodejs:20" - armhf: "ghcr.io/home-assistant/armhf-base-nodejs:20" + amd64: "ghcr.io/home-assistant/amd64-base-nodejs:22" + aarch64: "ghcr.io/home-assistant/aarch64-base-nodejs:22" + armv7: "ghcr.io/home-assistant/armv7-base-nodejs:22" + armhf: "ghcr.io/home-assistant/armhf-base-nodejs:22" squash: false From 1523097cad3fa839ead9b8f834a46b60c05ffcc4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:05:58 +0000 Subject: [PATCH 06/70] Use public Node base image for addon builds --- README.md | 1 - flashforge-dashboard/Dockerfile | 4 ++-- flashforge-dashboard/build.yaml | 7 ------- 3 files changed, 2 insertions(+), 10 deletions(-) delete mode 100644 flashforge-dashboard/build.yaml diff --git a/README.md b/README.md index ff06be7..d39bb52 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,6 @@ Dashboard locale per stampanti FlashForge AD5M / AD5X / 5M Pro con: ├── repository.yaml └── flashforge-dashboard/ ├── config.yaml - ├── build.yaml ├── Dockerfile ├── run.sh ├── server.js diff --git a/flashforge-dashboard/Dockerfile b/flashforge-dashboard/Dockerfile index b2d162b..e27d8ac 100644 --- a/flashforge-dashboard/Dockerfile +++ b/flashforge-dashboard/Dockerfile @@ -1,5 +1,5 @@ -ARG BUILD_FROM -FROM $BUILD_FROM +ARG BUILD_FROM=docker.io/library/node:22-alpine +FROM ${BUILD_FROM} # Copy application WORKDIR /app diff --git a/flashforge-dashboard/build.yaml b/flashforge-dashboard/build.yaml deleted file mode 100644 index 6d1a8ff..0000000 --- a/flashforge-dashboard/build.yaml +++ /dev/null @@ -1,7 +0,0 @@ -build_from: - amd64: "ghcr.io/home-assistant/amd64-base-nodejs:22" - aarch64: "ghcr.io/home-assistant/aarch64-base-nodejs:22" - armv7: "ghcr.io/home-assistant/armv7-base-nodejs:22" - armhf: "ghcr.io/home-assistant/armhf-base-nodejs:22" - -squash: false From 2cdd1dff71a0182f1115200ac9a08d0620acb9c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:15:31 +0000 Subject: [PATCH 07/70] fix: use HA base image with bashio to resolve run.sh not found error --- flashforge-dashboard/Dockerfile | 5 ++++- flashforge-dashboard/build.yaml | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 flashforge-dashboard/build.yaml diff --git a/flashforge-dashboard/Dockerfile b/flashforge-dashboard/Dockerfile index e27d8ac..3d52f1e 100644 --- a/flashforge-dashboard/Dockerfile +++ b/flashforge-dashboard/Dockerfile @@ -1,6 +1,9 @@ -ARG BUILD_FROM=docker.io/library/node:22-alpine +ARG BUILD_FROM=ghcr.io/home-assistant/amd64-base:latest FROM ${BUILD_FROM} +# Install Node.js +RUN apk add --no-cache nodejs npm + # Copy application WORKDIR /app COPY package.json ./ diff --git a/flashforge-dashboard/build.yaml b/flashforge-dashboard/build.yaml new file mode 100644 index 0000000..ce42834 --- /dev/null +++ b/flashforge-dashboard/build.yaml @@ -0,0 +1,5 @@ +build_from: + amd64: ghcr.io/home-assistant/amd64-base:latest + aarch64: ghcr.io/home-assistant/aarch64-base:latest + armv7: ghcr.io/home-assistant/armv7-base:latest + armhf: ghcr.io/home-assistant/armhf-base:latest From 18ba8bb2959a2eed6ad6c11c196716ec0a0f5bfb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:16:01 +0000 Subject: [PATCH 08/70] docs: clarify BUILD_FROM default is a local dev fallback --- flashforge-dashboard/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flashforge-dashboard/Dockerfile b/flashforge-dashboard/Dockerfile index 3d52f1e..d105f49 100644 --- a/flashforge-dashboard/Dockerfile +++ b/flashforge-dashboard/Dockerfile @@ -1,3 +1,5 @@ +# BUILD_FROM is injected by the HA build system based on build.yaml. +# The default below is a local-dev fallback for x86_64 machines only. ARG BUILD_FROM=ghcr.io/home-assistant/amd64-base:latest FROM ${BUILD_FROM} From 6e88f2974968abc7c87179c0787302845c647867 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:27:20 +0000 Subject: [PATCH 09/70] fix: add printer timeout and proper error handling in fetchStatus --- flashforge-dashboard/frontend/public/app.js | 7 +++++ flashforge-dashboard/server.js | 33 +++++++++++++++------ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/flashforge-dashboard/frontend/public/app.js b/flashforge-dashboard/frontend/public/app.js index 2202a7b..8102079 100644 --- a/flashforge-dashboard/frontend/public/app.js +++ b/flashforge-dashboard/frontend/public/app.js @@ -82,11 +82,18 @@ async function fetchStatus() { try { const res = await fetch(`${BASE}/api/status`); const json = await res.json(); + if (!res.ok || json.error) { + badge.textContent = 'Errore connessione'; + badge.className = 'badge badge--error'; + lastUpdate.textContent = `Errore: ${json.error || res.statusText}`; + return; + } if (json.detail) updateUI(json.detail); lastUpdate.textContent = `Ultimo aggiornamento: ${new Date().toLocaleTimeString('it-IT')}`; } catch (e) { badge.textContent = 'Errore connessione'; badge.className = 'badge badge--error'; + lastUpdate.textContent = `Errore: ${e.message}`; } } diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index cebdfe9..51fcb2f 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -34,19 +34,34 @@ const upload = multer({ storage: multer.memoryStorage() }); /** * POST to the printer's HTTP REST API with standard auth fields. + * Times out after PRINTER_TIMEOUT_MS to avoid hanging indefinitely. */ +const PRINTER_TIMEOUT_MS = 8000; + async function printerPost(endpoint, body = {}) { const payload = { serialNumber: SERIAL_NUMBER, checkCode: CHECK_CODE, ...body }; - const res = await fetch(`${PRINTER_API}${endpoint}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`Printer returned ${res.status}: ${text}`); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), PRINTER_TIMEOUT_MS); + try { + const res = await fetch(`${PRINTER_API}${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Printer returned ${res.status}: ${text}`); + } + return res.json(); + } catch (err) { + if (err.name === 'AbortError') { + throw new Error(`Printer did not respond within ${PRINTER_TIMEOUT_MS / 1000}s (${PRINTER_IP})`); + } + throw err; + } finally { + clearTimeout(timer); } - return res.json(); } /** From d84692013ea0c9ffa324c1e59eaf54adfd06125d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:28:14 +0000 Subject: [PATCH 10/70] fix: use PRINTER_API in timeout error message for clarity --- flashforge-dashboard/server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 51fcb2f..0438e99 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -56,7 +56,7 @@ async function printerPost(endpoint, body = {}) { return res.json(); } catch (err) { if (err.name === 'AbortError') { - throw new Error(`Printer did not respond within ${PRINTER_TIMEOUT_MS / 1000}s (${PRINTER_IP})`); + throw new Error(`Printer did not respond within ${PRINTER_TIMEOUT_MS / 1000}s (${PRINTER_API})`); } throw err; } finally { From c8d965be5def3c1085c41995891f129922f47189 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:50:40 +0000 Subject: [PATCH 11/70] Fix ingress path resolution for add-on API calls --- flashforge-dashboard/frontend/public/app.js | 15 +++++++++++++-- flashforge-dashboard/run.sh | 1 + 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/flashforge-dashboard/frontend/public/app.js b/flashforge-dashboard/frontend/public/app.js index 8102079..94fda96 100644 --- a/flashforge-dashboard/frontend/public/app.js +++ b/flashforge-dashboard/frontend/public/app.js @@ -3,7 +3,13 @@ // window.INGRESS_PATH is injected at runtime by server.js when running as a // Home Assistant add-on. It is the URL prefix HA uses for the ingress proxy // (e.g. "/api/hassio_ingress/abc123"). When running standalone it is undefined. -const BASE = (window.INGRESS_PATH || '').replace(/\/$/, ''); +// Fallback: detect ingress prefix directly from current URL path. +function detectIngressFromPath(pathname) { + const match = pathname.match(/^\/api\/hassio_ingress\/[^/]+/); + return match ? match[0] : ''; +} +const detectedIngress = detectIngressFromPath(window.location.pathname); +const BASE = (window.INGRESS_PATH || detectedIngress || '').replace(/\/$/, ''); /* ── State ───────────────────────────────────────────────────────────────── */ let currentJobID = null; @@ -219,7 +225,12 @@ async function loadFiles() { fileList.innerHTML = '

Caricamento…

'; try { const res = await fetch(`${BASE}/api/files`); - const json = await res.json(); + const contentType = res.headers.get('content-type') || ''; + const text = await res.text(); + const json = contentType.includes('application/json') ? JSON.parse(text) : null; + if (!json) { + throw new Error(`Risposta non JSON (HTTP ${res.status})`); + } const files = json.gcodeList || []; if (!files.length) { fileList.innerHTML = '

Nessun file trovato nella stampante.

'; diff --git a/flashforge-dashboard/run.sh b/flashforge-dashboard/run.sh index 3632d49..3de816a 100644 --- a/flashforge-dashboard/run.sh +++ b/flashforge-dashboard/run.sh @@ -11,6 +11,7 @@ export SERIAL_NUMBER="$(bashio::config 'serial_number')" export CHECK_CODE="$(bashio::config 'check_code')" export PORT="8099" export NODE_ENV="production" +export INGRESS_PATH="$(bashio::addon.ingress_entry)" if bashio::config.is_empty 'printer_ip'; then bashio::log.warning "printer_ip is not configured. Set it in the add-on Configuration tab." From 8beac75a5668611967ec740174db7ea260a6e74c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:06:04 +0000 Subject: [PATCH 12/70] Plan camera polling and mobile UI adjustments --- flashforge-dashboard/package-lock.json | 1041 ++++++++++++++++++++++++ 1 file changed, 1041 insertions(+) create mode 100644 flashforge-dashboard/package-lock.json diff --git a/flashforge-dashboard/package-lock.json b/flashforge-dashboard/package-lock.json new file mode 100644 index 0000000..487dece --- /dev/null +++ b/flashforge-dashboard/package-lock.json @@ -0,0 +1,1041 @@ +{ + "name": "flashforge-dashboard", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "flashforge-dashboard", + "version": "1.0.0", + "dependencies": { + "express": "^4.19.2", + "multer": "^1.4.5-lts.1", + "node-fetch": "^2.7.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/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/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} From cca5adb0b617ca45bb7c9f871d1db1ba93fea087 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:06:34 +0000 Subject: [PATCH 13/70] Adjust camera refresh behavior and mobile layout --- flashforge-dashboard/frontend/public/app.js | 14 +++-- .../frontend/public/index.html | 3 +- .../frontend/public/style.css | 58 +++++++++++++++++++ 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/flashforge-dashboard/frontend/public/app.js b/flashforge-dashboard/frontend/public/app.js index 94fda96..bc5825e 100644 --- a/flashforge-dashboard/frontend/public/app.js +++ b/flashforge-dashboard/frontend/public/app.js @@ -22,6 +22,7 @@ const badge = document.getElementById('status-badge'); const cameraImg = document.getElementById('camera-img'); const cameraPlaceholder = document.getElementById('camera-placeholder'); const btnCameraOn = document.getElementById('btn-camera-on'); +const btnCameraRefresh = document.getElementById('btn-camera-refresh'); const btnCameraOff = document.getElementById('btn-camera-off'); const sFname = document.getElementById('s-filename'); const sProgress = document.getElementById('s-progress'); @@ -150,15 +151,14 @@ function updateUI(d) { btnResume.disabled = !isPaused; btnStop.disabled = !(isPrinting || isPaused); - // Camera: if the printer reports a stream URL, auto-enable - if (d.cameraStreamUrl && !cameraActive) { - enableCamera(); - } } /* ── Camera ──────────────────────────────────────────────────────────────── */ -function enableCamera() { +function updateCameraStream() { cameraImg.src = `${BASE}/api/camera/stream?t=${Date.now()}`; +} +function enableCamera() { + updateCameraStream(); cameraImg.classList.add('active'); cameraPlaceholder.classList.add('hidden'); cameraActive = true; @@ -177,6 +177,10 @@ btnCameraOn.addEventListener('click', async () => { enableCamera(); }); +btnCameraRefresh.addEventListener('click', () => { + if (cameraActive) updateCameraStream(); +}); + btnCameraOff.addEventListener('click', async () => { disableCamera(); try { diff --git a/flashforge-dashboard/frontend/public/index.html b/flashforge-dashboard/frontend/public/index.html index d673bc9..dd98dc6 100644 --- a/flashforge-dashboard/frontend/public/index.html +++ b/flashforge-dashboard/frontend/public/index.html @@ -27,6 +27,7 @@

Camera

+
@@ -142,7 +143,7 @@

Carica GCode

Non ancora aggiornato - Refresh automatico ogni 4s + Dati auto ogni 4s • Camera manuale
diff --git a/flashforge-dashboard/frontend/public/style.css b/flashforge-dashboard/frontend/public/style.css index 56dfc70..b40ffae 100644 --- a/flashforge-dashboard/frontend/public/style.css +++ b/flashforge-dashboard/frontend/public/style.css @@ -150,6 +150,7 @@ main { overflow: hidden; text-overflow: ellipsis; } +#s-layer { font-size: 16px; } /* ── Progress bar ────────────────────────────────────────────────────────── */ .progress-bar-wrap { @@ -339,6 +340,63 @@ footer { margin: 0 auto; } +@media (max-width: 640px) { + header { padding: 0 14px; } + header h1 { font-size: 15px; } + .badge { font-size: 11px; padding: 4px 10px; } + + main { + margin: 14px auto; + padding: 0 14px; + gap: 14px; + } + + .card { padding: 14px; } + + .camera-controls { + display: grid; + grid-template-columns: 1fr; + gap: 8px; + } + + .stat-grid { + grid-template-columns: 1fr; + gap: 8px; + } + .stat { padding: 9px 10px; } + .stat-value { font-size: 15px; white-space: normal; } + #s-layer { font-size: 14px; } + + .temp-grid { + flex-direction: column; + gap: 8px; + } + .temp-card { padding: 8px 10px; } + .temp-value { font-size: 18px; } + + .print-controls .btn { + flex: 1 1 100%; + } + + .file-list { max-height: 240px; } + .file-item { padding: 8px 10px; gap: 8px; } + + .modal-box { + width: calc(100vw - 28px); + padding: 16px; + } + .modal-actions { + flex-direction: column; + } + + footer { + flex-direction: column; + gap: 4px; + align-items: flex-start; + padding: 10px 14px; + } +} + /* ── Scrollbar ───────────────────────────────────────────────────────────── */ ::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: transparent; } From 0fec8cf7f7a72dd9a19da59fac933f82352ddb27 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:07:50 +0000 Subject: [PATCH 14/70] Refine camera refresh UX and remove unintended lockfile --- flashforge-dashboard/frontend/public/app.js | 2 + .../frontend/public/index.html | 2 +- flashforge-dashboard/package-lock.json | 1041 ----------------- 3 files changed, 3 insertions(+), 1042 deletions(-) delete mode 100644 flashforge-dashboard/package-lock.json diff --git a/flashforge-dashboard/frontend/public/app.js b/flashforge-dashboard/frontend/public/app.js index bc5825e..299a0ac 100644 --- a/flashforge-dashboard/frontend/public/app.js +++ b/flashforge-dashboard/frontend/public/app.js @@ -162,12 +162,14 @@ function enableCamera() { cameraImg.classList.add('active'); cameraPlaceholder.classList.add('hidden'); cameraActive = true; + btnCameraRefresh.disabled = false; } function disableCamera() { cameraImg.src = ''; cameraImg.classList.remove('active'); cameraPlaceholder.classList.remove('hidden'); cameraActive = false; + btnCameraRefresh.disabled = true; } btnCameraOn.addEventListener('click', async () => { diff --git a/flashforge-dashboard/frontend/public/index.html b/flashforge-dashboard/frontend/public/index.html index dd98dc6..8e37584 100644 --- a/flashforge-dashboard/frontend/public/index.html +++ b/flashforge-dashboard/frontend/public/index.html @@ -27,7 +27,7 @@

Camera

- +
diff --git a/flashforge-dashboard/package-lock.json b/flashforge-dashboard/package-lock.json deleted file mode 100644 index 487dece..0000000 --- a/flashforge-dashboard/package-lock.json +++ /dev/null @@ -1,1041 +0,0 @@ -{ - "name": "flashforge-dashboard", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "flashforge-dashboard", - "version": "1.0.0", - "dependencies": { - "express": "^4.19.2", - "multer": "^1.4.5-lts.1", - "node-fetch": "^2.7.0" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/append-field": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", - "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", - "license": "MIT" - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" - }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", - "engines": [ - "node >= 0.8" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^2.2.2", - "typedarray": "^0.0.6" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.15.1", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/multer": { - "version": "1.4.5-lts.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", - "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", - "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", - "license": "MIT", - "dependencies": { - "append-field": "^1.0.0", - "busboy": "^1.0.0", - "concat-stream": "^1.5.2", - "mkdirp": "^0.5.4", - "object-assign": "^4.1.1", - "type-is": "^1.6.4", - "xtend": "^4.0.0" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "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/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/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/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - } - } -} From 4078ac9a4dd01094ed38b539d884efc955c8ccd0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:08:44 +0000 Subject: [PATCH 15/70] Polish mobile stat sizing and footer text --- flashforge-dashboard/frontend/public/index.html | 4 ++-- flashforge-dashboard/frontend/public/style.css | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/flashforge-dashboard/frontend/public/index.html b/flashforge-dashboard/frontend/public/index.html index 8e37584..6fc1c83 100644 --- a/flashforge-dashboard/frontend/public/index.html +++ b/flashforge-dashboard/frontend/public/index.html @@ -47,7 +47,7 @@

Stato stampa

Layer - +
Tempo rimanente @@ -143,7 +143,7 @@

Carica GCode

Non ancora aggiornato - Dati auto ogni 4s • Camera manuale + dati auto ogni 4s • Camera manuale
diff --git a/flashforge-dashboard/frontend/public/style.css b/flashforge-dashboard/frontend/public/style.css index b40ffae..7873a8f 100644 --- a/flashforge-dashboard/frontend/public/style.css +++ b/flashforge-dashboard/frontend/public/style.css @@ -150,7 +150,7 @@ main { overflow: hidden; text-overflow: ellipsis; } -#s-layer { font-size: 16px; } +.stat-value--compact { font-size: 16px; } /* ── Progress bar ────────────────────────────────────────────────────────── */ .progress-bar-wrap { @@ -365,7 +365,7 @@ footer { } .stat { padding: 9px 10px; } .stat-value { font-size: 15px; white-space: normal; } - #s-layer { font-size: 14px; } + .stat-value--compact { font-size: 14px; } .temp-grid { flex-direction: column; From 13aa34a408789d32e53379579af0428a216f5884 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:09:09 +0000 Subject: [PATCH 16/70] Clarify footer auto-update text --- flashforge-dashboard/frontend/public/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flashforge-dashboard/frontend/public/index.html b/flashforge-dashboard/frontend/public/index.html index 6fc1c83..5f402fd 100644 --- a/flashforge-dashboard/frontend/public/index.html +++ b/flashforge-dashboard/frontend/public/index.html @@ -143,7 +143,7 @@

Carica GCode

Non ancora aggiornato - dati auto ogni 4s • Camera manuale + dati aggiornati ogni 4s • Camera manuale
From ed5ba0aaa4f862a5a518177de01b4965d64db011 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:09:37 +0000 Subject: [PATCH 17/70] Normalize footer wording casing --- flashforge-dashboard/frontend/public/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flashforge-dashboard/frontend/public/index.html b/flashforge-dashboard/frontend/public/index.html index 5f402fd..7ce71db 100644 --- a/flashforge-dashboard/frontend/public/index.html +++ b/flashforge-dashboard/frontend/public/index.html @@ -143,7 +143,7 @@

Carica GCode

Non ancora aggiornato - dati aggiornati ogni 4s • Camera manuale + dati aggiornati ogni 4s • camera manuale
From 415f5224b053665f04d43a6ca6a02049f5502338 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:27:10 +0000 Subject: [PATCH 18/70] redesign: single-column UI, direct MJPEG camera stream, pairs grid layout --- flashforge-dashboard/frontend/public/app.js | 17 ++---- .../frontend/public/index.html | 49 +++++++-------- .../frontend/public/style.css | 60 ++++++------------- 3 files changed, 43 insertions(+), 83 deletions(-) diff --git a/flashforge-dashboard/frontend/public/app.js b/flashforge-dashboard/frontend/public/app.js index 299a0ac..fc1ae07 100644 --- a/flashforge-dashboard/frontend/public/app.js +++ b/flashforge-dashboard/frontend/public/app.js @@ -16,13 +16,13 @@ let currentJobID = null; let currentStatus = null; let pollingTimer = null; let cameraActive = false; +let cameraStreamUrl = null; /* ── DOM refs ────────────────────────────────────────────────────────────── */ const badge = document.getElementById('status-badge'); const cameraImg = document.getElementById('camera-img'); const cameraPlaceholder = document.getElementById('camera-placeholder'); const btnCameraOn = document.getElementById('btn-camera-on'); -const btnCameraRefresh = document.getElementById('btn-camera-refresh'); const btnCameraOff = document.getElementById('btn-camera-off'); const sFname = document.getElementById('s-filename'); const sProgress = document.getElementById('s-progress'); @@ -154,22 +154,18 @@ function updateUI(d) { } /* ── Camera ──────────────────────────────────────────────────────────────── */ -function updateCameraStream() { - cameraImg.src = `${BASE}/api/camera/stream?t=${Date.now()}`; -} function enableCamera() { - updateCameraStream(); + if (!cameraStreamUrl) return; + cameraImg.src = cameraStreamUrl; cameraImg.classList.add('active'); cameraPlaceholder.classList.add('hidden'); cameraActive = true; - btnCameraRefresh.disabled = false; } function disableCamera() { cameraImg.src = ''; cameraImg.classList.remove('active'); cameraPlaceholder.classList.remove('hidden'); cameraActive = false; - btnCameraRefresh.disabled = true; } btnCameraOn.addEventListener('click', async () => { @@ -179,10 +175,6 @@ btnCameraOn.addEventListener('click', async () => { enableCamera(); }); -btnCameraRefresh.addEventListener('click', () => { - if (cameraActive) updateCameraStream(); -}); - btnCameraOff.addEventListener('click', async () => { disableCamera(); try { @@ -406,11 +398,12 @@ function setUploadPct(pct) { // Check configuration try { const cfg = await fetch(`${BASE}/api/config`).then(r => r.json()); + if (cfg.cameraUrl) cameraStreamUrl = cfg.cameraUrl; if (!cfg.configured) { badge.textContent = 'NON CONFIGURATO'; badge.className = 'badge badge--error'; document.querySelector('main').insertAdjacentHTML('afterbegin', - `
+ `
⚠️ La stampante non è configurata. Vai su Impostazioni → Add-on → FlashForge Dashboard → Configurazione e inserisci printer_ip, serial_number e check_code. diff --git a/flashforge-dashboard/frontend/public/index.html b/flashforge-dashboard/frontend/public/index.html index 7ce71db..4577fa8 100644 --- a/flashforge-dashboard/frontend/public/index.html +++ b/flashforge-dashboard/frontend/public/index.html @@ -26,9 +26,8 @@

Camera

- - - + +
@@ -36,30 +35,11 @@

Camera

Stato stampa

-
-
- File - -
-
- Progresso - -
-
- Layer - -
-
- Tempo rimanente - -
-
-
-
+
🔥 Ugello @@ -75,13 +55,26 @@

Stato stampa

-
- - -
diff --git a/flashforge-dashboard/frontend/public/style.css b/flashforge-dashboard/frontend/public/style.css index 7873a8f..69a54b2 100644 --- a/flashforge-dashboard/frontend/public/style.css +++ b/flashforge-dashboard/frontend/public/style.css @@ -36,7 +36,7 @@ header { } .header-inner { width: 100%; - max-width: 1400px; + max-width: 860px; margin: 0 auto; display: flex; align-items: center; @@ -60,19 +60,14 @@ header h1 { font-size: 18px; font-weight: 600; letter-spacing: .5px; } /* ── Main grid ──────────────────────────────────────────────────────────── */ main { - max-width: 1400px; + max-width: 860px; margin: 24px auto; padding: 0 24px; - display: grid; - grid-template-columns: 1fr 1fr; - grid-template-rows: auto auto; + display: flex; + flex-direction: column; gap: 20px; } -@media (max-width: 860px) { - main { grid-template-columns: 1fr; } -} - /* ── Card ────────────────────────────────────────────────────────────────── */ .card { background: var(--surface); @@ -90,7 +85,6 @@ main { } /* ── Camera ─────────────────────────────────────────────────────────────── */ -.card--camera { grid-column: 1; } .camera-wrap { width: 100%; @@ -121,14 +115,6 @@ main { .camera-controls { display: flex; gap: 10px; } /* ── Stat grid ──────────────────────────────────────────────────────────── */ -.card--status { grid-column: 2; } - -.stat-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; - margin-bottom: 16px; -} .stat { background: var(--surface2); border-radius: 8px; @@ -168,14 +154,18 @@ main { transition: width .5s ease; } -/* ── Temp cards ─────────────────────────────────────────────────────────── */ -.temp-grid { - display: flex; - gap: 12px; - margin-bottom: 16px; +/* ── Pairs grid (temps + stats + controls in 2 columns) ─────────────────── */ +.pairs-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-bottom: 12px; } +.pairs-grid .btn { width: 100%; } +.btn--full { grid-column: 1 / -1; } + +/* ── Temp cards ─────────────────────────────────────────────────────────── */ .temp-card { - flex: 1; background: var(--surface2); border-radius: 8px; padding: 12px; @@ -188,7 +178,6 @@ main { .temp-target { font-size: 11px; color: var(--text-muted); } /* ── Print controls ─────────────────────────────────────────────────────── */ -.print-controls { display: flex; gap: 10px; flex-wrap: wrap; } .ctrl-message { margin-top: 10px; font-size: 12px; color: var(--text-muted); min-height: 16px; } /* ── Buttons ─────────────────────────────────────────────────────────────── */ @@ -212,7 +201,6 @@ main { .btn--danger { background: var(--danger); color: #fff; } /* ── File list ──────────────────────────────────────────────────────────── */ -.card--files { grid-column: 1; } .files-toolbar { margin-bottom: 12px; } @@ -281,7 +269,6 @@ main { .modal-actions { display: flex; gap: 10px; margin-top: 18px; } /* ── Upload ─────────────────────────────────────────────────────────────── */ -.card--upload { grid-column: 2; } .file-drop-area { border: 2px dashed var(--border); @@ -336,7 +323,7 @@ footer { justify-content: space-between; font-size: 11px; color: var(--text-muted); - max-width: 1400px; + max-width: 860px; margin: 0 auto; } @@ -353,31 +340,18 @@ footer { .card { padding: 14px; } - .camera-controls { - display: grid; + .pairs-grid { grid-template-columns: 1fr; - gap: 8px; } + .btn--full { grid-column: 1; } - .stat-grid { - grid-template-columns: 1fr; - gap: 8px; - } .stat { padding: 9px 10px; } .stat-value { font-size: 15px; white-space: normal; } .stat-value--compact { font-size: 14px; } - .temp-grid { - flex-direction: column; - gap: 8px; - } .temp-card { padding: 8px 10px; } .temp-value { font-size: 18px; } - .print-controls .btn { - flex: 1 1 100%; - } - .file-list { max-height: 240px; } .file-item { padding: 8px 10px; gap: 8px; } From e8da7c999c93de97519b012cf77b9b853a5a8a75 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:49:57 +0000 Subject: [PATCH 19/70] Confirm existing runtime setup --- flashforge-dashboard/package-lock.json | 1041 ++++++++++++++++++++++++ 1 file changed, 1041 insertions(+) create mode 100644 flashforge-dashboard/package-lock.json diff --git a/flashforge-dashboard/package-lock.json b/flashforge-dashboard/package-lock.json new file mode 100644 index 0000000..487dece --- /dev/null +++ b/flashforge-dashboard/package-lock.json @@ -0,0 +1,1041 @@ +{ + "name": "flashforge-dashboard", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "flashforge-dashboard", + "version": "1.0.0", + "dependencies": { + "express": "^4.19.2", + "multer": "^1.4.5-lts.1", + "node-fetch": "^2.7.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/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/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} From 97a6fd761949eccfea1fb993d8f5487c552c9e15 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:51:31 +0000 Subject: [PATCH 20/70] Fix printer controls and status layout --- flashforge-dashboard/frontend/public/app.js | 67 ++++++++++++++++--- .../frontend/public/index.html | 16 ++--- .../frontend/public/style.css | 25 +++---- flashforge-dashboard/server.js | 36 ++++++---- 4 files changed, 98 insertions(+), 46 deletions(-) diff --git a/flashforge-dashboard/frontend/public/app.js b/flashforge-dashboard/frontend/public/app.js index fc1ae07..a38aa86 100644 --- a/flashforge-dashboard/frontend/public/app.js +++ b/flashforge-dashboard/frontend/public/app.js @@ -38,6 +38,7 @@ const tChamberTarget = document.getElementById('t-chamber-target'); const btnPause = document.getElementById('btn-pause'); const btnResume = document.getElementById('btn-resume'); const btnStop = document.getElementById('btn-stop'); +const btnClearState = document.getElementById('btn-clear-state'); const ctrlMsg = document.getElementById('ctrl-message'); const lastUpdate = document.getElementById('last-update'); @@ -83,6 +84,9 @@ function showUploadMsg(msg, ok = true) { uploadMessage.textContent = msg; uploadMessage.style.color = ok ? 'var(--success)' : 'var(--danger)'; } +function normalizeStatus(status) { + return String(status || 'ready').trim().toUpperCase(); +} /* ── Status polling ──────────────────────────────────────────────────────── */ async function fetchStatus() { @@ -105,22 +109,29 @@ async function fetchStatus() { } function updateUI(d) { - currentStatus = d.status || 'IDLE'; + currentStatus = normalizeStatus(d.status); // Badge const statusMap = { + READY: ['badge--idle', 'PRONTA'], + BUSY: ['badge--printing', 'BUSY'], + HEATING: ['badge--printing', 'RISCALDAMENTO'], PRINTING: ['badge--printing', 'STAMPA'], + PAUSING: ['badge--paused', 'IN PAUSA'], PAUSED: ['badge--paused', 'IN PAUSA'], + COMPLETED:['badge--success', 'COMPLETATA'], + CANCEL: ['badge--error', 'ANNULLATA'], IDLE: ['badge--idle', 'INATTIVA'], ERROR: ['badge--error', 'ERRORE'], HOMING: ['badge--printing', 'HOMING'], + CALIBRATE_DOING: ['badge--printing', 'CALIBRAZIONE'], }; const [cls, label] = statusMap[currentStatus] || ['badge--idle', currentStatus]; badge.className = `badge ${cls}`; badge.textContent = label; // Job info - currentJobID = d.jobInfo ? (d.jobInfo[0] && d.jobInfo[0][1]) : null; + currentJobID = d.jobInfo ? (d.jobInfo[0] && d.jobInfo[0][1]) : ''; sFname.textContent = d.printFileName || '—'; sFname.title = d.printFileName || ''; @@ -145,11 +156,15 @@ function updateUI(d) { tChamberTarget.textContent = d.chamberTargetTemp ? `→ ${fmt(d.chamberTargetTemp)}` : ''; // Controls enable/disable - const isPrinting = currentStatus === 'PRINTING'; - const isPaused = currentStatus === 'PAUSED'; - btnPause.disabled = !isPrinting; + const hasControllableJob = Boolean(d.printFileName || currentJobID || pct !== null); + const canPause = hasControllableJob && ['PRINTING', 'BUSY', 'HEATING'].includes(currentStatus); + const isPaused = currentStatus === 'PAUSED'; + const canStop = hasControllableJob && ['PRINTING', 'BUSY', 'HEATING', 'PAUSED', 'PAUSING'].includes(currentStatus); + const canClearState = ['BUSY', 'COMPLETED', 'CANCEL'].includes(currentStatus); + btnPause.disabled = !canPause; btnResume.disabled = !isPaused; - btnStop.disabled = !(isPrinting || isPaused); + btnStop.disabled = !canStop; + btnClearState.disabled = !canClearState; } @@ -188,19 +203,47 @@ cameraImg.addEventListener('error', () => { /* ── Print controls ──────────────────────────────────────────────────────── */ async function sendControl(action) { - if (!currentJobID) { showCtrlMsg('Nessun job attivo.', false); return; } + const actionMap = { + pause: 'pause', + resume: 'continue', + stop: 'cancel', + }; + const printerAction = actionMap[action] || action; + const canSendWithoutJobId = ['PRINTING', 'BUSY', 'HEATING', 'PAUSED', 'PAUSING'].includes(currentStatus); + if (!currentJobID && !canSendWithoutJobId) { + showCtrlMsg('Nessun job attivo.', false); + return; + } try { const res = await fetch(`${BASE}/api/control`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action, jobID: currentJobID }), + body: JSON.stringify({ action: printerAction, jobID: currentJobID || '' }), }); const json = await res.json(); - if (json.code === 0) { + if (res.ok && json.code === 0) { showCtrlMsg(`Comando "${action}" inviato.`); await fetchStatus(); } else { - showCtrlMsg(`Errore: ${json.message || json.code}`, false); + showCtrlMsg(`Errore: ${json.error || json.message || json.code}`, false); + } + } catch (e) { + showCtrlMsg(`Errore di rete: ${e.message}`, false); + } +} + +async function clearPrinterState() { + try { + const res = await fetch(`${BASE}/api/state/clear`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + const json = await res.json(); + if (res.ok && json.code === 0) { + showCtrlMsg('Stato stampante ripulito.'); + await fetchStatus(); + } else { + showCtrlMsg(`Errore: ${json.error || json.message || json.code}`, false); } } catch (e) { showCtrlMsg(`Errore di rete: ${e.message}`, false); @@ -213,6 +256,10 @@ btnStop.addEventListener('click', async () => { if (!confirm('Sei sicuro di voler interrompere la stampa?')) return; await sendControl('stop'); }); +btnClearState.addEventListener('click', async () => { + if (!confirm('Vuoi ripulire lo stato della stampante e riportarla in ready?')) return; + await clearPrinterState(); +}); /* ── File list ───────────────────────────────────────────────────────────── */ let selectedFileName = null; diff --git a/flashforge-dashboard/frontend/public/index.html b/flashforge-dashboard/frontend/public/index.html index 4577fa8..5c28a5e 100644 --- a/flashforge-dashboard/frontend/public/index.html +++ b/flashforge-dashboard/frontend/public/index.html @@ -40,6 +40,10 @@

Stato stampa

+
+ File + +
🔥 Ugello @@ -50,7 +54,7 @@

Stato stampa

-
+
📦 Camera @@ -59,22 +63,18 @@

Stato stampa

Layer
-
- File - -
Progresso
-
+
Tempo rimanente
-
- + +
diff --git a/flashforge-dashboard/frontend/public/style.css b/flashforge-dashboard/frontend/public/style.css index 69a54b2..8c73c6b 100644 --- a/flashforge-dashboard/frontend/public/style.css +++ b/flashforge-dashboard/frontend/public/style.css @@ -120,6 +120,7 @@ main { border-radius: 8px; padding: 12px 14px; } +.stat--full { grid-column: 1 / -1; } .stat-label { display: block; font-size: 11px; @@ -157,25 +158,26 @@ main { /* ── Pairs grid (temps + stats + controls in 2 columns) ─────────────────── */ .pairs-grid { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 12px; } .pairs-grid .btn { width: 100%; } -.btn--full { grid-column: 1 / -1; } /* ── Temp cards ─────────────────────────────────────────────────────────── */ .temp-card { background: var(--surface2); border-radius: 8px; - padding: 12px; - display: flex; - flex-direction: column; - gap: 4px; + padding: 10px 12px; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 6px 10px; } +.temp-card--full { grid-column: 1 / -1; } .temp-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .5px; } -.temp-value { font-size: 22px; font-weight: 700; } -.temp-target { font-size: 11px; color: var(--text-muted); } +.temp-value { font-size: 18px; font-weight: 700; min-width: 0; } +.temp-target { font-size: 11px; color: var(--text-muted); justify-self: end; } /* ── Print controls ─────────────────────────────────────────────────────── */ .ctrl-message { margin-top: 10px; font-size: 12px; color: var(--text-muted); min-height: 16px; } @@ -340,17 +342,12 @@ footer { .card { padding: 14px; } - .pairs-grid { - grid-template-columns: 1fr; - } - .btn--full { grid-column: 1; } - .stat { padding: 9px 10px; } .stat-value { font-size: 15px; white-space: normal; } .stat-value--compact { font-size: 14px; } .temp-card { padding: 8px 10px; } - .temp-value { font-size: 18px; } + .temp-value { font-size: 16px; } .file-list { max-height: 240px; } .file-item { padding: 8px 10px; gap: 8px; } diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 0438e99..6248763 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -64,6 +64,15 @@ async function printerPost(endpoint, body = {}) { } } +async function printerControl(cmd, args = {}) { + return printerPost('/control', { + payload: { + cmd, + args, + }, + }); +} + /** * Validate that required env vars are set and return 503 otherwise. */ @@ -97,16 +106,20 @@ app.get('/api/status', requireConfig, async (req, res) => { */ app.post('/api/control', requireConfig, async (req, res) => { const { action, jobID } = req.body; - if (!action || !jobID) { - return res.status(400).json({ error: 'action and jobID are required' }); + if (!action) { + return res.status(400).json({ error: 'action is required' }); } try { - const data = await printerPost('/control', { - payload: { - cmd: 'jobCtl_cmd', - args: { jobID, action }, - }, - }); + const data = await printerControl('jobCtl_cmd', { jobID: jobID || '', action }); + res.json(data); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +app.post('/api/state/clear', requireConfig, async (req, res) => { + try { + const data = await printerControl('stateCtrl_cmd', { action: 'setClearPlatform' }); res.json(data); } catch (err) { res.status(502).json({ error: err.message }); @@ -245,12 +258,7 @@ app.post('/api/camera', requireConfig, async (req, res) => { const { action } = req.body; if (!action) return res.status(400).json({ error: 'action is required' }); try { - const data = await printerPost('/control', { - payload: { - cmd: 'streamCtrl_cmd', - args: { action }, - }, - }); + const data = await printerControl('streamCtrl_cmd', { action }); res.json(data); } catch (err) { res.status(502).json({ error: err.message }); From b53e108c49b0ead1ba519ae80d7a15bac425c7bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:52:03 +0000 Subject: [PATCH 21/70] Polish status badges and controls --- flashforge-dashboard/frontend/public/app.js | 2 +- flashforge-dashboard/frontend/public/style.css | 1 + flashforge-dashboard/server.js | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/flashforge-dashboard/frontend/public/app.js b/flashforge-dashboard/frontend/public/app.js index a38aa86..99e4ecf 100644 --- a/flashforge-dashboard/frontend/public/app.js +++ b/flashforge-dashboard/frontend/public/app.js @@ -114,7 +114,7 @@ function updateUI(d) { // Badge const statusMap = { READY: ['badge--idle', 'PRONTA'], - BUSY: ['badge--printing', 'BUSY'], + BUSY: ['badge--printing', 'OCCUPATA'], HEATING: ['badge--printing', 'RISCALDAMENTO'], PRINTING: ['badge--printing', 'STAMPA'], PAUSING: ['badge--paused', 'IN PAUSA'], diff --git a/flashforge-dashboard/frontend/public/style.css b/flashforge-dashboard/frontend/public/style.css index 8c73c6b..1de2d62 100644 --- a/flashforge-dashboard/frontend/public/style.css +++ b/flashforge-dashboard/frontend/public/style.css @@ -56,6 +56,7 @@ header h1 { font-size: 18px; font-weight: 600; letter-spacing: .5px; } .badge--idle { background: rgba(122,127,154,.2); color: var(--text-muted); } .badge--printing { background: rgba(79,142,247,.2); color: var(--accent); } .badge--paused { background: rgba(240,165,0,.2); color: var(--warning); } +.badge--success { background: rgba(62,207,116,.2); color: var(--success); } .badge--error { background: rgba(224,76,76,.2); color: var(--danger); } /* ── Main grid ──────────────────────────────────────────────────────────── */ diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 6248763..6c864e6 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -102,7 +102,7 @@ app.get('/api/status', requireConfig, async (req, res) => { /** * POST /api/control - * Body: { action: "pause"|"resume"|"stop", jobID: "..." } + * Body: { action: "pause"|"continue"|"cancel", jobID?: "..." } */ app.post('/api/control', requireConfig, async (req, res) => { const { action, jobID } = req.body; From 7ed55218e73ca11502fb58c213d1779f58e14161 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:50:11 +0000 Subject: [PATCH 22/70] Add MQTT discovery and HTTP add-on exposure --- README.md | 27 +++ flashforge-dashboard/config.yaml | 17 ++ flashforge-dashboard/run.sh | 8 + flashforge-dashboard/server.js | 335 +++++++++++++++++++++++++++++++ 4 files changed, 387 insertions(+) diff --git a/README.md b/README.md index d39bb52..656fd84 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Dashboard locale per stampanti FlashForge AD5M / AD5X / 5M Pro con: - temperature - controlli pausa/riprendi/stop - lista file e upload GCode +- integrazione MQTT Discovery per sensori e switch in Home Assistant ## Struttura repository @@ -42,3 +43,29 @@ Dashboard locale per stampanti FlashForge AD5M / AD5X / 5M Pro con: ## Note Progetto non ufficiale, non affiliato con FlashForge o Home Assistant. + +## Accesso dashboard + +- **Ingress Home Assistant**: da sidebar (come prima) +- **HTTP diretto**: `http://:8099` + +## Integrazione automatica Home Assistant (MQTT) + +L’add-on pubblica automaticamente sensori/switch via **MQTT Discovery**. + +Prerequisiti: +- integrazione MQTT configurata in Home Assistant +- broker MQTT raggiungibile dall’add-on (default: `core-mosquitto`) + +Opzioni configurabili nell’add-on: +- `mqtt_enabled` (default `true`) +- `mqtt_host` (default `core-mosquitto`) +- `mqtt_port` (default `1883`) +- `mqtt_username` / `mqtt_password` (opzionali) +- `mqtt_base_topic` (default `flashforge`) + +Entità principali esposte: +- sensori: stato, progress, temperature, tempo stimato +- binary sensor: stampa in corso +- switch: pausa/riprendi, camera stream +- button: stop stampa, clear stato stampante diff --git a/flashforge-dashboard/config.yaml b/flashforge-dashboard/config.yaml index 4e1bb41..76c117c 100644 --- a/flashforge-dashboard/config.yaml +++ b/flashforge-dashboard/config.yaml @@ -19,16 +19,33 @@ ingress: true ingress_port: 8099 panel_icon: mdi:printer-3d panel_title: FlashForge +webui: "http://[HOST]:[PORT:8099]" +ports: + 8099/tcp: 8099 +ports_description: + 8099/tcp: Dashboard HTTP # Opzioni configurabili dal pannello HA (Add-on → Configurazione) options: printer_ip: "" serial_number: "" check_code: "" + mqtt_enabled: true + mqtt_host: "core-mosquitto" + mqtt_port: 1883 + mqtt_username: "" + mqtt_password: "" + mqtt_base_topic: "flashforge" schema: printer_ip: str serial_number: str check_code: str + mqtt_enabled: bool + mqtt_host: str + mqtt_port: port + mqtt_username: str? + mqtt_password: password? + mqtt_base_topic: str url: "https://github.com/MikManenti/flashforge-api-docs" diff --git a/flashforge-dashboard/run.sh b/flashforge-dashboard/run.sh index 3de816a..b0ebc13 100644 --- a/flashforge-dashboard/run.sh +++ b/flashforge-dashboard/run.sh @@ -12,6 +12,12 @@ export CHECK_CODE="$(bashio::config 'check_code')" export PORT="8099" export NODE_ENV="production" export INGRESS_PATH="$(bashio::addon.ingress_entry)" +export MQTT_ENABLED="$(bashio::config 'mqtt_enabled')" +export MQTT_HOST="$(bashio::config 'mqtt_host')" +export MQTT_PORT="$(bashio::config 'mqtt_port')" +export MQTT_USERNAME="$(bashio::config 'mqtt_username')" +export MQTT_PASSWORD="$(bashio::config 'mqtt_password')" +export MQTT_BASE_TOPIC="$(bashio::config 'mqtt_base_topic')" if bashio::config.is_empty 'printer_ip'; then bashio::log.warning "printer_ip is not configured. Set it in the add-on Configuration tab." @@ -25,5 +31,7 @@ fi bashio::log.info "Printer IP: ${PRINTER_IP}" bashio::log.info "Listening on port ${PORT}" +bashio::log.info "MQTT enabled: ${MQTT_ENABLED}" +bashio::log.info "MQTT broker: ${MQTT_HOST}:${MQTT_PORT}" exec node /app/server.js diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 6c864e6..ac7983d 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -5,6 +5,7 @@ const express = require('express'); const multer = require('multer'); const fetch = require('node-fetch'); +const mqtt = require('mqtt'); const fs = require('fs'); const path = require('path'); const http = require('http'); @@ -22,6 +23,26 @@ const INGRESS_PATH = (process.env.INGRESS_PATH || '').replace(/\/$/, ''); const PRINTER_API = `http://${PRINTER_IP}:8898`; const CAMERA_URL = `http://${PRINTER_IP}:8080/?action=stream`; +const MQTT_ENABLED = String(process.env.MQTT_ENABLED || 'true').toLowerCase() === 'true'; +const MQTT_HOST = process.env.MQTT_HOST || 'core-mosquitto'; +const MQTT_PORT = Number(process.env.MQTT_PORT || 1883); +const MQTT_USERNAME = process.env.MQTT_USERNAME || ''; +const MQTT_PASSWORD = process.env.MQTT_PASSWORD || ''; +const MQTT_BASE_TOPIC = sanitizeTopic(process.env.MQTT_BASE_TOPIC || 'flashforge'); +const MQTT_POLL_INTERVAL_MS = 10000; + +const DEVICE_ID = String(SERIAL_NUMBER || PRINTER_IP || 'flashforge_printer') + .replace(/[^\w-]/g, '_') + .toLowerCase(); +const DEVICE_NAME = SERIAL_NUMBER ? `FlashForge ${SERIAL_NUMBER}` : 'FlashForge Printer'; +const MQTT_ROOT_TOPIC = `${MQTT_BASE_TOPIC}/${DEVICE_ID}`; +const MQTT_AVAILABILITY_TOPIC = `${MQTT_ROOT_TOPIC}/availability`; + +let mqttClient = null; +let mqttConnected = false; +let mqttDiscoveryPublished = false; +let lastPrinterDetail = null; +let cameraSwitchState = 'OFF'; // ── Middleware ────────────────────────────────────────────────────────────── app.use(express.json()); @@ -73,6 +94,287 @@ async function printerControl(cmd, args = {}) { }); } +function sanitizeTopic(topic) { + return String(topic || 'flashforge') + .trim() + .replace(/^[\/\s]+|[\/\s]+$/g, '') + .replace(/\s+/g, '_'); +} + +function mqttPublish(topic, payload, options = {}) { + if (!mqttClient || !mqttConnected) return; + mqttClient.publish(topic, String(payload), { qos: 0, retain: false, ...options }); +} + +function getCurrentJobId() { + if (!lastPrinterDetail || !Array.isArray(lastPrinterDetail.jobInfo)) return ''; + return (lastPrinterDetail.jobInfo[0] && lastPrinterDetail.jobInfo[0][1]) || ''; +} + +function publishMqttState(detail) { + if (!detail || !mqttConnected) return; + + const normalizedStatus = String(detail.status || 'ready').trim().toUpperCase(); + const progress = detail.printProgress != null ? Math.round(detail.printProgress * 100) : 0; + const isPrinting = ['PRINTING', 'BUSY', 'HEATING', 'PAUSED', 'PAUSING'].includes(normalizedStatus); + const pauseSwitchState = ['PAUSED', 'PAUSING'].includes(normalizedStatus) ? 'ON' : 'OFF'; + + mqttPublish(`${MQTT_ROOT_TOPIC}/state/status`, normalizedStatus, { retain: true }); + mqttPublish(`${MQTT_ROOT_TOPIC}/state/progress`, progress, { retain: true }); + mqttPublish(`${MQTT_ROOT_TOPIC}/state/file_name`, detail.printFileName || '', { retain: true }); + mqttPublish(`${MQTT_ROOT_TOPIC}/state/nozzle_temp`, detail.rightTemp ?? '', { retain: true }); + mqttPublish(`${MQTT_ROOT_TOPIC}/state/bed_temp`, detail.platTemp ?? '', { retain: true }); + mqttPublish(`${MQTT_ROOT_TOPIC}/state/chamber_temp`, detail.chamberTemp ?? '', { retain: true }); + mqttPublish(`${MQTT_ROOT_TOPIC}/state/estimated_time_s`, detail.estimatedTime ?? '', { retain: true }); + mqttPublish(`${MQTT_ROOT_TOPIC}/state/layer_current`, detail.printLayer ?? '', { retain: true }); + mqttPublish(`${MQTT_ROOT_TOPIC}/state/layer_target`, detail.targetPrintLayer ?? '', { retain: true }); + mqttPublish(`${MQTT_ROOT_TOPIC}/state/is_printing`, isPrinting ? 'ON' : 'OFF', { retain: true }); + mqttPublish(`${MQTT_ROOT_TOPIC}/state/pause_switch`, pauseSwitchState, { retain: true }); + mqttPublish(`${MQTT_ROOT_TOPIC}/state/camera_switch`, cameraSwitchState, { retain: true }); +} + +function updatePrinterDetail(detail) { + if (!detail) return; + lastPrinterDetail = detail; + publishMqttState(detail); +} + +function createMqttDeviceInfo() { + return { + identifiers: [DEVICE_ID], + name: DEVICE_NAME, + manufacturer: 'FlashForge', + model: 'AD5 Series', + }; +} + +function publishMqttDiscovery() { + if (!mqttConnected || mqttDiscoveryPublished) return; + const device = createMqttDeviceInfo(); + const discoveryBase = 'homeassistant'; + const publishDiscovery = (component, objectId, payload) => { + const topic = `${discoveryBase}/${component}/${DEVICE_ID}/${objectId}/config`; + mqttPublish(topic, JSON.stringify(payload), { retain: true }); + }; + + publishDiscovery('sensor', 'status', { + name: 'Status', + unique_id: `${DEVICE_ID}_status`, + state_topic: `${MQTT_ROOT_TOPIC}/state/status`, + icon: 'mdi:printer-3d-nozzle-alert', + availability_topic: MQTT_AVAILABILITY_TOPIC, + device, + }); + publishDiscovery('sensor', 'progress', { + name: 'Progress', + unique_id: `${DEVICE_ID}_progress`, + state_topic: `${MQTT_ROOT_TOPIC}/state/progress`, + unit_of_measurement: '%', + icon: 'mdi:progress-clock', + availability_topic: MQTT_AVAILABILITY_TOPIC, + device, + }); + publishDiscovery('sensor', 'nozzle_temp', { + name: 'Nozzle Temperature', + unique_id: `${DEVICE_ID}_nozzle_temp`, + state_topic: `${MQTT_ROOT_TOPIC}/state/nozzle_temp`, + unit_of_measurement: '°C', + device_class: 'temperature', + availability_topic: MQTT_AVAILABILITY_TOPIC, + device, + }); + publishDiscovery('sensor', 'bed_temp', { + name: 'Bed Temperature', + unique_id: `${DEVICE_ID}_bed_temp`, + state_topic: `${MQTT_ROOT_TOPIC}/state/bed_temp`, + unit_of_measurement: '°C', + device_class: 'temperature', + availability_topic: MQTT_AVAILABILITY_TOPIC, + device, + }); + publishDiscovery('sensor', 'chamber_temp', { + name: 'Chamber Temperature', + unique_id: `${DEVICE_ID}_chamber_temp`, + state_topic: `${MQTT_ROOT_TOPIC}/state/chamber_temp`, + unit_of_measurement: '°C', + device_class: 'temperature', + availability_topic: MQTT_AVAILABILITY_TOPIC, + device, + }); + publishDiscovery('sensor', 'estimated_time', { + name: 'Estimated Time', + unique_id: `${DEVICE_ID}_estimated_time_s`, + state_topic: `${MQTT_ROOT_TOPIC}/state/estimated_time_s`, + unit_of_measurement: 's', + icon: 'mdi:timer-outline', + availability_topic: MQTT_AVAILABILITY_TOPIC, + device, + }); + publishDiscovery('binary_sensor', 'is_printing', { + name: 'Printing', + unique_id: `${DEVICE_ID}_is_printing`, + state_topic: `${MQTT_ROOT_TOPIC}/state/is_printing`, + payload_on: 'ON', + payload_off: 'OFF', + availability_topic: MQTT_AVAILABILITY_TOPIC, + device, + }); + publishDiscovery('switch', 'pause_resume', { + name: 'Pause Print', + unique_id: `${DEVICE_ID}_pause_resume`, + state_topic: `${MQTT_ROOT_TOPIC}/state/pause_switch`, + command_topic: `${MQTT_ROOT_TOPIC}/command/pause_resume`, + payload_on: 'PAUSE', + payload_off: 'RESUME', + state_on: 'ON', + state_off: 'OFF', + icon: 'mdi:pause-circle', + availability_topic: MQTT_AVAILABILITY_TOPIC, + device, + }); + publishDiscovery('switch', 'camera', { + name: 'Camera Stream', + unique_id: `${DEVICE_ID}_camera_stream`, + state_topic: `${MQTT_ROOT_TOPIC}/state/camera_switch`, + command_topic: `${MQTT_ROOT_TOPIC}/command/camera`, + payload_on: 'OPEN', + payload_off: 'CLOSE', + state_on: 'ON', + state_off: 'OFF', + icon: 'mdi:cctv', + availability_topic: MQTT_AVAILABILITY_TOPIC, + device, + }); + publishDiscovery('button', 'stop', { + name: 'Stop Print', + unique_id: `${DEVICE_ID}_stop`, + command_topic: `${MQTT_ROOT_TOPIC}/command/stop`, + payload_press: 'STOP', + icon: 'mdi:stop-circle', + availability_topic: MQTT_AVAILABILITY_TOPIC, + device, + }); + publishDiscovery('button', 'clear_state', { + name: 'Clear Printer State', + unique_id: `${DEVICE_ID}_clear_state`, + command_topic: `${MQTT_ROOT_TOPIC}/command/clear_state`, + payload_press: 'CLEAR', + icon: 'mdi:broom', + availability_topic: MQTT_AVAILABILITY_TOPIC, + device, + }); + + mqttDiscoveryPublished = true; +} + +async function refreshPrinterState() { + if (!PRINTER_IP || !SERIAL_NUMBER || !CHECK_CODE) return; + try { + const data = await printerPost('/detail'); + if (data && data.detail) { + updatePrinterDetail(data.detail); + } + } catch (err) { + console.warn(`MQTT state refresh failed: ${err.message}`); + } +} + +function isTruthyPayload(payload) { + return ['1', 'ON', 'TRUE', 'OPEN', 'PAUSE', 'STOP', 'CLEAR', 'PRESS', 'RESUME', 'CONTINUE', 'CLOSE', '0', 'OFF', 'FALSE'] + .includes(payload); +} + +async function handleMqttCommand(topic, payloadRaw) { + const payload = String(payloadRaw || '').trim().toUpperCase(); + if (!payload || !isTruthyPayload(payload)) return; + + if (topic === `${MQTT_ROOT_TOPIC}/command/camera`) { + const action = ['OPEN', 'ON', '1', 'TRUE'].includes(payload) ? 'open' : 'close'; + await printerControl('streamCtrl_cmd', { action }); + cameraSwitchState = action === 'open' ? 'ON' : 'OFF'; + mqttPublish(`${MQTT_ROOT_TOPIC}/state/camera_switch`, cameraSwitchState, { retain: true }); + return; + } + + if (topic === `${MQTT_ROOT_TOPIC}/command/pause_resume`) { + const action = ['PAUSE', 'ON', '1', 'TRUE'].includes(payload) ? 'pause' : 'continue'; + await printerControl('jobCtl_cmd', { jobID: getCurrentJobId(), action }); + await refreshPrinterState(); + return; + } + + if (topic === `${MQTT_ROOT_TOPIC}/command/stop`) { + if (!['STOP', 'PRESS', 'ON', '1', 'TRUE'].includes(payload)) return; + await printerControl('jobCtl_cmd', { jobID: getCurrentJobId(), action: 'cancel' }); + await refreshPrinterState(); + return; + } + + if (topic === `${MQTT_ROOT_TOPIC}/command/clear_state`) { + if (!['CLEAR', 'PRESS', 'ON', '1', 'TRUE'].includes(payload)) return; + await printerControl('stateCtrl_cmd', { action: 'setClearPlatform' }); + await refreshPrinterState(); + } +} + +function setupMqtt() { + if (!MQTT_ENABLED) { + console.log('MQTT disabled via configuration.'); + return; + } + + const mqttUrl = `mqtt://${MQTT_HOST}:${MQTT_PORT}`; + const options = { + reconnectPeriod: 5000, + will: { + topic: MQTT_AVAILABILITY_TOPIC, + payload: 'offline', + retain: true, + }, + }; + if (MQTT_USERNAME) options.username = MQTT_USERNAME; + if (MQTT_PASSWORD) options.password = MQTT_PASSWORD; + + mqttClient = mqtt.connect(mqttUrl, options); + + mqttClient.on('connect', async () => { + mqttConnected = true; + mqttDiscoveryPublished = false; + console.log(`Connected to MQTT broker at ${mqttUrl}`); + mqttPublish(MQTT_AVAILABILITY_TOPIC, 'online', { retain: true }); + publishMqttDiscovery(); + await refreshPrinterState(); + + const commandTopics = [ + `${MQTT_ROOT_TOPIC}/command/camera`, + `${MQTT_ROOT_TOPIC}/command/pause_resume`, + `${MQTT_ROOT_TOPIC}/command/stop`, + `${MQTT_ROOT_TOPIC}/command/clear_state`, + ]; + mqttClient.subscribe(commandTopics, (err) => { + if (err) { + console.warn(`MQTT subscribe error: ${err.message}`); + } + }); + }); + + mqttClient.on('message', async (topic, payload) => { + try { + await handleMqttCommand(topic, payload); + } catch (err) { + console.warn(`MQTT command error on ${topic}: ${err.message}`); + } + }); + + mqttClient.on('error', (err) => { + console.warn(`MQTT error: ${err.message}`); + }); + + mqttClient.on('close', () => { + mqttConnected = false; + }); +} + /** * Validate that required env vars are set and return 503 otherwise. */ @@ -94,6 +396,9 @@ function requireConfig(req, res, next) { app.get('/api/status', requireConfig, async (req, res) => { try { const data = await printerPost('/detail'); + if (data && data.detail) { + updatePrinterDetail(data.detail); + } res.json(data); } catch (err) { res.status(502).json({ error: err.message }); @@ -111,6 +416,7 @@ app.post('/api/control', requireConfig, async (req, res) => { } try { const data = await printerControl('jobCtl_cmd', { jobID: jobID || '', action }); + await refreshPrinterState(); res.json(data); } catch (err) { res.status(502).json({ error: err.message }); @@ -120,6 +426,7 @@ app.post('/api/control', requireConfig, async (req, res) => { app.post('/api/state/clear', requireConfig, async (req, res) => { try { const data = await printerControl('stateCtrl_cmd', { action: 'setClearPlatform' }); + await refreshPrinterState(); res.json(data); } catch (err) { res.status(502).json({ error: err.message }); @@ -164,6 +471,7 @@ app.post('/api/print', requireConfig, async (req, res) => { if (!fileName) return res.status(400).json({ error: 'fileName is required' }); try { const data = await printerPost('/printGcode', { fileName, levelingBeforePrint }); + await refreshPrinterState(); res.json(data); } catch (err) { res.status(502).json({ error: err.message }); @@ -213,6 +521,9 @@ app.post('/api/upload', requireConfig, upload.single('gcodeFile'), async (req, r }); const result = await printerRes.json().catch(() => ({ code: printerRes.status })); + if (printerRes.ok && result.code === 0) { + await refreshPrinterState(); + } res.status(printerRes.ok ? 200 : 502).json(result); }); @@ -259,6 +570,8 @@ app.post('/api/camera', requireConfig, async (req, res) => { if (!action) return res.status(400).json({ error: 'action is required' }); try { const data = await printerControl('streamCtrl_cmd', { action }); + cameraSwitchState = action === 'open' ? 'ON' : 'OFF'; + mqttPublish(`${MQTT_ROOT_TOPIC}/state/camera_switch`, cameraSwitchState, { retain: true }); res.json(data); } catch (err) { res.status(502).json({ error: err.message }); @@ -296,7 +609,29 @@ app.get('*', serveIndex); app.listen(PORT, () => { console.log(`FlashForge Dashboard (HA add-on) running on port ${PORT}`); console.log(`Ingress path: ${INGRESS_PATH || '(none)'}`); + console.log(`Direct HTTP URL: http://0.0.0.0:${PORT}`); if (!PRINTER_IP || !SERIAL_NUMBER || !CHECK_CODE) { console.warn('⚠ printer_ip, serial_number or check_code not set. Configure them in the HA add-on Configuration tab.'); } + setupMqtt(); + if (MQTT_ENABLED) { + setInterval(() => { + refreshPrinterState(); + }, MQTT_POLL_INTERVAL_MS); + } }); + +function shutdown() { + if (mqttClient) { + try { + mqttPublish(MQTT_AVAILABILITY_TOPIC, 'offline', { retain: true }); + mqttClient.end(true); + } catch (_) { + // ignore shutdown errors + } + } + process.exit(0); +} + +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); From af11808b5c0a7fd07f8d9ad04a138377a2370d67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:50:55 +0000 Subject: [PATCH 23/70] Add MQTT dependency for auto-discovery integration --- flashforge-dashboard/package-lock.json | 534 +++++++++++++++++++++++++ flashforge-dashboard/package.json | 1 + 2 files changed, 535 insertions(+) diff --git a/flashforge-dashboard/package-lock.json b/flashforge-dashboard/package-lock.json index 487dece..8cfae5f 100644 --- a/flashforge-dashboard/package-lock.json +++ b/flashforge-dashboard/package-lock.json @@ -9,10 +9,59 @@ "version": "1.0.0", "dependencies": { "express": "^4.19.2", + "mqtt": "^5.15.1", "multer": "^1.4.5-lts.1", "node-fetch": "^2.7.0" } }, + "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/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/readable-stream": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz", + "integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==", + "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/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": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -38,6 +87,63 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "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/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": { "version": "1.20.5", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", @@ -62,6 +168,42 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/broker-factory": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.14.tgz", + "integrity": "sha512-L45k5HMbPIrMid0nTOZ/UPXG/c0aRuQKVrSDFIb1zOkvfiyHgYmIjc3cSiN1KwQIvRDOtKE0tfb3I9EZ3CmpQQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1", + "worker-factory": "^7.0.49" + } + }, + "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-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -117,6 +259,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", @@ -276,6 +424,24 @@ "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": { "version": "4.22.2", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", @@ -322,6 +488,19 @@ "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/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -440,6 +619,12 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -472,12 +657,41 @@ "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/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -493,6 +707,22 @@ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, + "node_modules/js-sdsl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", + "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "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/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -583,6 +813,149 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mqtt": { + "version": "5.15.1", + "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.15.1.tgz", + "integrity": "sha512-V1WnkGuJh3ec9QXzy5Iylw8OOBK+Xu1WhxcQ9mMpLThG+/JZIMV1PgLNRgIiqXhZnvnVLsuyxHl5A/3bHHbcAA==", + "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", + "number-allocator": "^1.0.14", + "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -637,6 +1010,39 @@ } } }, + "node_modules/number-allocator": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz", + "integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "js-sdsl": "4.3.0" + } + }, + "node_modules/number-allocator/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/number-allocator/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/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -685,6 +1091,15 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -764,6 +1179,12 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "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/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -913,6 +1334,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "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": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -960,6 +1414,12 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -979,6 +1439,12 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "license": "MIT" }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1028,6 +1494,74 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/worker-factory": { + "version": "7.0.49", + "resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.49.tgz", + "integrity": "sha512-lW7tpgy6aUv2dFsQhv1yv+XFzdkCf/leoKRTGMPVK5/die6RrUjqgJHJf556qO+ZfytNG6wPXc17E8zzsOLUDw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1" + } + }, + "node_modules/worker-timers": { + "version": "8.0.31", + "resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.31.tgz", + "integrity": "sha512-ngkq5S6JuZyztom8tDgBzorLo9byhBMko/sXfgiUD945AuzKGg1GCgDMCC3NaYkicLpGKXutONM36wEX8UbBCA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1", + "worker-timers-broker": "^8.0.16", + "worker-timers-worker": "^9.0.14" + } + }, + "node_modules/worker-timers-broker": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.16.tgz", + "integrity": "sha512-JyP3AvUGyPGbBGW7XiUewm2+0pN/aYo1QpVf5kdXAfkDZcN3p7NbWrG6XnyDEpDIvfHk/+LCnOW/NsuiU9riYA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "broker-factory": "^3.1.14", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1", + "worker-timers-worker": "^9.0.14" + } + }, + "node_modules/worker-timers-worker": { + "version": "9.0.14", + "resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.14.tgz", + "integrity": "sha512-/qF06C60sXmSLfUl7WglvrDIbspmPOM8UrG63Dnn4bi2x4/DfqHS/+dxF5B+MdHnYO5tVuZYLHdAodrKdabTIg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1", + "worker-factory": "^7.0.49" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "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": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/flashforge-dashboard/package.json b/flashforge-dashboard/package.json index e0cc972..11d12fa 100644 --- a/flashforge-dashboard/package.json +++ b/flashforge-dashboard/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "express": "^4.19.2", + "mqtt": "^5.15.1", "multer": "^1.4.5-lts.1", "node-fetch": "^2.7.0" } From 4615423a092a2fe2c915efdf7361f083725816ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:52:05 +0000 Subject: [PATCH 24/70] Refine MQTT command naming and shutdown cleanup --- flashforge-dashboard/server.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index ac7983d..241bad8 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -43,6 +43,7 @@ let mqttConnected = false; let mqttDiscoveryPublished = false; let lastPrinterDetail = null; let cameraSwitchState = 'OFF'; +let mqttPollingTimer = null; // ── Middleware ────────────────────────────────────────────────────────────── app.use(express.json()); @@ -279,14 +280,14 @@ async function refreshPrinterState() { } } -function isTruthyPayload(payload) { +function isKnownCommandPayload(payload) { return ['1', 'ON', 'TRUE', 'OPEN', 'PAUSE', 'STOP', 'CLEAR', 'PRESS', 'RESUME', 'CONTINUE', 'CLOSE', '0', 'OFF', 'FALSE'] .includes(payload); } async function handleMqttCommand(topic, payloadRaw) { const payload = String(payloadRaw || '').trim().toUpperCase(); - if (!payload || !isTruthyPayload(payload)) return; + if (!payload || !isKnownCommandPayload(payload)) return; if (topic === `${MQTT_ROOT_TOPIC}/command/camera`) { const action = ['OPEN', 'ON', '1', 'TRUE'].includes(payload) ? 'open' : 'close'; @@ -615,13 +616,17 @@ app.listen(PORT, () => { } setupMqtt(); if (MQTT_ENABLED) { - setInterval(() => { + mqttPollingTimer = setInterval(() => { refreshPrinterState(); }, MQTT_POLL_INTERVAL_MS); } }); function shutdown() { + if (mqttPollingTimer) { + clearInterval(mqttPollingTimer); + mqttPollingTimer = null; + } if (mqttClient) { try { mqttPublish(MQTT_AVAILABILITY_TOPIC, 'offline', { retain: true }); From 9aa27b7d5e80b15ff815fe97153e15e7cd922efe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:53:15 +0000 Subject: [PATCH 25/70] Tighten MQTT config parsing and cleanup logging --- flashforge-dashboard/server.js | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 241bad8..2abb311 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -15,6 +15,9 @@ const PORT = process.env.PORT || 8099; const PRINTER_IP = process.env.PRINTER_IP; const SERIAL_NUMBER = process.env.SERIAL_NUMBER; const CHECK_CODE = process.env.CHECK_CODE; +const KNOWN_MQTT_COMMAND_PAYLOADS = new Set([ + '1', 'ON', 'TRUE', 'OPEN', 'PAUSE', 'STOP', 'CLEAR', 'PRESS', 'RESUME', 'CONTINUE', 'CLOSE', '0', 'OFF', 'FALSE', +]); // HA Ingress sets this env var to the URL prefix it uses when proxying // (e.g. "/api/hassio_ingress/abc123"). The frontend needs this to build @@ -23,7 +26,7 @@ const INGRESS_PATH = (process.env.INGRESS_PATH || '').replace(/\/$/, ''); const PRINTER_API = `http://${PRINTER_IP}:8898`; const CAMERA_URL = `http://${PRINTER_IP}:8080/?action=stream`; -const MQTT_ENABLED = String(process.env.MQTT_ENABLED || 'true').toLowerCase() === 'true'; +const MQTT_ENABLED = parseBooleanEnv(process.env.MQTT_ENABLED, true); const MQTT_HOST = process.env.MQTT_HOST || 'core-mosquitto'; const MQTT_PORT = Number(process.env.MQTT_PORT || 1883); const MQTT_USERNAME = process.env.MQTT_USERNAME || ''; @@ -102,6 +105,14 @@ function sanitizeTopic(topic) { .replace(/\s+/g, '_'); } +function parseBooleanEnv(value, defaultValue = false) { + if (value === undefined || value === null || value === '') return defaultValue; + const normalized = String(value).trim().toLowerCase(); + if (['true', '1', 'yes', 'on'].includes(normalized)) return true; + if (['false', '0', 'no', 'off'].includes(normalized)) return false; + return defaultValue; +} + function mqttPublish(topic, payload, options = {}) { if (!mqttClient || !mqttConnected) return; mqttClient.publish(topic, String(payload), { qos: 0, retain: false, ...options }); @@ -281,8 +292,7 @@ async function refreshPrinterState() { } function isKnownCommandPayload(payload) { - return ['1', 'ON', 'TRUE', 'OPEN', 'PAUSE', 'STOP', 'CLEAR', 'PRESS', 'RESUME', 'CONTINUE', 'CLOSE', '0', 'OFF', 'FALSE'] - .includes(payload); + return KNOWN_MQTT_COMMAND_PAYLOADS.has(payload); } async function handleMqttCommand(topic, payloadRaw) { @@ -631,8 +641,8 @@ function shutdown() { try { mqttPublish(MQTT_AVAILABILITY_TOPIC, 'offline', { retain: true }); mqttClient.end(true); - } catch (_) { - // ignore shutdown errors + } catch (err) { + console.warn(`MQTT shutdown warning: ${err.message}`); } } process.exit(0); From 717edfa83aa5cbc78e5d1a025530260e8dd4c8bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:54:28 +0000 Subject: [PATCH 26/70] Clarify HTTP URL log and MQTT shutdown behavior --- flashforge-dashboard/server.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 2abb311..0faf9ff 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -620,7 +620,7 @@ app.get('*', serveIndex); app.listen(PORT, () => { console.log(`FlashForge Dashboard (HA add-on) running on port ${PORT}`); console.log(`Ingress path: ${INGRESS_PATH || '(none)'}`); - console.log(`Direct HTTP URL: http://0.0.0.0:${PORT}`); + console.log(`Direct HTTP URL: http://:${PORT}`); if (!PRINTER_IP || !SERIAL_NUMBER || !CHECK_CODE) { console.warn('⚠ printer_ip, serial_number or check_code not set. Configure them in the HA add-on Configuration tab.'); } @@ -640,6 +640,7 @@ function shutdown() { if (mqttClient) { try { mqttPublish(MQTT_AVAILABILITY_TOPIC, 'offline', { retain: true }); + // Force close to avoid hanging shutdown in add-on restarts. mqttClient.end(true); } catch (err) { console.warn(`MQTT shutdown warning: ${err.message}`); From eff0b477d4eab090c73983a8885716a4b8ab85e0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:58:04 +0000 Subject: [PATCH 27/70] fix: call /printGcode explicitly after upload when printNow=1 --- flashforge-dashboard/server.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 0faf9ff..895c77d 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -533,6 +533,18 @@ app.post('/api/upload', requireConfig, upload.single('gcodeFile'), async (req, r const result = await printerRes.json().catch(() => ({ code: printerRes.status })); if (printerRes.ok && result.code === 0) { + if (printNow === '1') { + try { + await printerPost('/printGcode', { + fileName: req.file.originalname, + levelingBeforePrint: levelingBeforePrint === '1', + }); + } catch (err) { + // Upload succeeded; report the print-start failure without blocking the response + await refreshPrinterState(); + return res.status(200).json({ code: 0, printStartError: err.message }); + } + } await refreshPrinterState(); } res.status(printerRes.ok ? 200 : 502).json(result); From 405384af43dc773e9ae22264e0d4c82f214ebc17 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:34:28 +0000 Subject: [PATCH 28/70] feat: use Frigate as video stream source instead of direct printer camera --- flashforge-dashboard/config.yaml | 2 ++ flashforge-dashboard/run.sh | 1 + flashforge-dashboard/server.js | 33 +++++++++++++++++++++++--------- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/flashforge-dashboard/config.yaml b/flashforge-dashboard/config.yaml index 76c117c..7b38bfc 100644 --- a/flashforge-dashboard/config.yaml +++ b/flashforge-dashboard/config.yaml @@ -36,6 +36,7 @@ options: mqtt_username: "" mqtt_password: "" mqtt_base_topic: "flashforge" + frigate_url: "" schema: printer_ip: str @@ -47,5 +48,6 @@ schema: mqtt_username: str? mqtt_password: password? mqtt_base_topic: str + frigate_url: url? url: "https://github.com/MikManenti/flashforge-api-docs" diff --git a/flashforge-dashboard/run.sh b/flashforge-dashboard/run.sh index b0ebc13..16e189c 100644 --- a/flashforge-dashboard/run.sh +++ b/flashforge-dashboard/run.sh @@ -18,6 +18,7 @@ export MQTT_PORT="$(bashio::config 'mqtt_port')" export MQTT_USERNAME="$(bashio::config 'mqtt_username')" export MQTT_PASSWORD="$(bashio::config 'mqtt_password')" export MQTT_BASE_TOPIC="$(bashio::config 'mqtt_base_topic')" +export FRIGATE_URL="$(bashio::config 'frigate_url')" if bashio::config.is_empty 'printer_ip'; then bashio::log.warning "printer_ip is not configured. Set it in the add-on Configuration tab." diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 895c77d..9efd5dc 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -9,6 +9,7 @@ const mqtt = require('mqtt'); const fs = require('fs'); const path = require('path'); const http = require('http'); +const https = require('https'); const app = express(); const PORT = process.env.PORT || 8099; @@ -25,7 +26,8 @@ const KNOWN_MQTT_COMMAND_PAYLOADS = new Set([ const INGRESS_PATH = (process.env.INGRESS_PATH || '').replace(/\/$/, ''); const PRINTER_API = `http://${PRINTER_IP}:8898`; -const CAMERA_URL = `http://${PRINTER_IP}:8080/?action=stream`; +const FRIGATE_URL = (process.env.FRIGATE_URL || '').trim(); +const CAMERA_URL = FRIGATE_URL || (PRINTER_IP ? `http://${PRINTER_IP}:8080/?action=stream` : ''); const MQTT_ENABLED = parseBooleanEnv(process.env.MQTT_ENABLED, true); const MQTT_HOST = process.env.MQTT_HOST || 'core-mosquitto'; const MQTT_PORT = Number(process.env.MQTT_PORT || 1883); @@ -301,7 +303,9 @@ async function handleMqttCommand(topic, payloadRaw) { if (topic === `${MQTT_ROOT_TOPIC}/command/camera`) { const action = ['OPEN', 'ON', '1', 'TRUE'].includes(payload) ? 'open' : 'close'; - await printerControl('streamCtrl_cmd', { action }); + if (!FRIGATE_URL) { + await printerControl('streamCtrl_cmd', { action }); + } cameraSwitchState = action === 'open' ? 'ON' : 'OFF'; mqttPublish(`${MQTT_ROOT_TOPIC}/state/camera_switch`, cameraSwitchState, { retain: true }); return; @@ -552,19 +556,23 @@ app.post('/api/upload', requireConfig, upload.single('gcodeFile'), async (req, r /** * GET /api/camera/stream - * Proxies the MJPEG stream from the printer camera so the browser - * can display it without cross-origin issues. + * Proxies the MJPEG stream from Frigate (or the printer camera as fallback) + * so the browser can display it without cross-origin issues. */ app.get('/api/camera/stream', requireConfig, (req, res) => { + if (!CAMERA_URL) { + return res.status(503).json({ error: 'No camera source configured' }); + } const url = new URL(CAMERA_URL); + const transport = url.protocol === 'https:' ? https : http; const options = { hostname: url.hostname, - port: url.port || 8080, + port: url.port || (url.protocol === 'https:' ? 443 : 80), path: url.pathname + url.search, method: 'GET', }; - const proxyReq = http.request(options, (proxyRes) => { + const proxyReq = transport.request(options, (proxyRes) => { res.writeHead(proxyRes.statusCode, { 'Content-Type': proxyRes.headers['content-type'] || 'multipart/x-mixed-replace', 'Cache-Control': 'no-cache', @@ -586,13 +594,18 @@ app.get('/api/camera/stream', requireConfig, (req, res) => { /** * POST /api/camera * Body: { action: "open"|"close" } - * Enables or disables the camera stream on the printer. + * Enables or disables the camera stream. + * When Frigate is configured the printer's streamCtrl_cmd is skipped + * because Frigate manages the stream independently. */ app.post('/api/camera', requireConfig, async (req, res) => { const { action } = req.body; if (!action) return res.status(400).json({ error: 'action is required' }); try { - const data = await printerControl('streamCtrl_cmd', { action }); + let data = {}; + if (!FRIGATE_URL) { + data = await printerControl('streamCtrl_cmd', { action }); + } cameraSwitchState = action === 'open' ? 'ON' : 'OFF'; mqttPublish(`${MQTT_ROOT_TOPIC}/state/camera_switch`, cameraSwitchState, { retain: true }); res.json(data); @@ -603,10 +616,12 @@ app.post('/api/camera', requireConfig, async (req, res) => { // ── Config check endpoint ──────────────────────────────────────────────────── app.get('/api/config', (req, res) => { + const hasCameraSource = !!(FRIGATE_URL || PRINTER_IP); res.json({ configured: !!(PRINTER_IP && SERIAL_NUMBER && CHECK_CODE), printerIp: PRINTER_IP || null, - cameraUrl: PRINTER_IP ? CAMERA_URL : null, + cameraUrl: hasCameraSource ? `${INGRESS_PATH}/api/camera/stream` : null, + frigateEnabled: !!FRIGATE_URL, ingressPath: INGRESS_PATH, }); }); From cf4af8c02ade93ff3796be9d1d1f26f139cb23bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:35:35 +0000 Subject: [PATCH 29/70] fix: use null for absent CAMERA_URL and derive cameraUrl from CAMERA_URL directly --- flashforge-dashboard/server.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 9efd5dc..0539c60 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -27,7 +27,7 @@ const INGRESS_PATH = (process.env.INGRESS_PATH || '').replace(/\/$/, ''); const PRINTER_API = `http://${PRINTER_IP}:8898`; const FRIGATE_URL = (process.env.FRIGATE_URL || '').trim(); -const CAMERA_URL = FRIGATE_URL || (PRINTER_IP ? `http://${PRINTER_IP}:8080/?action=stream` : ''); +const CAMERA_URL = FRIGATE_URL || (PRINTER_IP ? `http://${PRINTER_IP}:8080/?action=stream` : null); const MQTT_ENABLED = parseBooleanEnv(process.env.MQTT_ENABLED, true); const MQTT_HOST = process.env.MQTT_HOST || 'core-mosquitto'; const MQTT_PORT = Number(process.env.MQTT_PORT || 1883); @@ -616,11 +616,10 @@ app.post('/api/camera', requireConfig, async (req, res) => { // ── Config check endpoint ──────────────────────────────────────────────────── app.get('/api/config', (req, res) => { - const hasCameraSource = !!(FRIGATE_URL || PRINTER_IP); res.json({ configured: !!(PRINTER_IP && SERIAL_NUMBER && CHECK_CODE), printerIp: PRINTER_IP || null, - cameraUrl: hasCameraSource ? `${INGRESS_PATH}/api/camera/stream` : null, + cameraUrl: CAMERA_URL ? `${INGRESS_PATH}/api/camera/stream` : null, frigateEnabled: !!FRIGATE_URL, ingressPath: INGRESS_PATH, }); From d78dfa8da080cc3e3ac766c7f8bc3b5c60df4ce3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:57:50 +0000 Subject: [PATCH 30/70] feat: add RTSP stream support via ffmpeg for Frigate camera MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dockerfile: install ffmpeg alongside nodejs/npm - config.yaml: change frigate_url schema from url? to str? to accept rtsp:// URLs - server.js: add streamRtspAsMjpeg() helper (ffmpeg → MJPEG transcoding) and route RTSP URLs to it in /api/camera/stream --- flashforge-dashboard/Dockerfile | 2 +- flashforge-dashboard/config.yaml | 2 +- flashforge-dashboard/server.js | 89 ++++++++++++++++++++++++++++++-- 3 files changed, 86 insertions(+), 7 deletions(-) diff --git a/flashforge-dashboard/Dockerfile b/flashforge-dashboard/Dockerfile index d105f49..2ecd131 100644 --- a/flashforge-dashboard/Dockerfile +++ b/flashforge-dashboard/Dockerfile @@ -4,7 +4,7 @@ ARG BUILD_FROM=ghcr.io/home-assistant/amd64-base:latest FROM ${BUILD_FROM} # Install Node.js -RUN apk add --no-cache nodejs npm +RUN apk add --no-cache nodejs npm ffmpeg # Copy application WORKDIR /app diff --git a/flashforge-dashboard/config.yaml b/flashforge-dashboard/config.yaml index 7b38bfc..c86108a 100644 --- a/flashforge-dashboard/config.yaml +++ b/flashforge-dashboard/config.yaml @@ -48,6 +48,6 @@ schema: mqtt_username: str? mqtt_password: password? mqtt_base_topic: str - frigate_url: url? + frigate_url: str? url: "https://github.com/MikManenti/flashforge-api-docs" diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 0539c60..0bb087e 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -10,6 +10,7 @@ const fs = require('fs'); const path = require('path'); const http = require('http'); const https = require('https'); +const { spawn } = require('child_process'); const app = express(); const PORT = process.env.PORT || 8099; @@ -554,21 +555,99 @@ app.post('/api/upload', requireConfig, upload.single('gcodeFile'), async (req, r res.status(printerRes.ok ? 200 : 502).json(result); }); +/** + * Transcodes an RTSP stream to HTTP MJPEG using ffmpeg. + * Spawns ffmpeg, reads raw JPEG frames from its stdout, and wraps them in a + * multipart/x-mixed-replace response so the browser tag can display it. + */ +function streamRtspAsMjpeg(rtspUrl, req, res) { + const BOUNDARY = 'mjpegstream'; + const MAX_PENDING = 4 * 1024 * 1024; // 4 MB – guard against a broken stream + + res.writeHead(200, { + 'Content-Type': `multipart/x-mixed-replace; boundary=${BOUNDARY}`, + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + + const ff = spawn('ffmpeg', [ + '-loglevel', 'error', + '-rtsp_transport', 'tcp', + '-i', rtspUrl, + '-vf', 'fps=10', + '-f', 'image2pipe', + '-vcodec', 'mjpeg', + '-q:v', '5', + 'pipe:1', + ]); + + let pending = Buffer.alloc(0); + const SOI = Buffer.from([0xff, 0xd8]); + const EOI = Buffer.from([0xff, 0xd9]); + + ff.stdout.on('data', (chunk) => { + pending = Buffer.concat([pending, chunk]); + + for (;;) { + const soi = pending.indexOf(SOI); + if (soi === -1) { + // Keep the last byte: the SOI marker might be split across chunks. + pending = pending.length > 0 ? pending.slice(-1) : Buffer.alloc(0); + break; + } + const eoi = pending.indexOf(EOI, soi + 2); + if (eoi === -1) { pending = pending.slice(soi); break; } + + const frame = pending.slice(soi, eoi + 2); + pending = pending.slice(eoi + 2); + + if (!res.writableEnded) { + 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 (pending.length > MAX_PENDING) pending = Buffer.alloc(0); + }); + + ff.stderr.on('data', (d) => console.error('[ffmpeg]', d.toString().trim())); + + ff.on('error', (err) => { + console.error('[ffmpeg] spawn error:', err.message); + if (!res.writableEnded) res.end(); + }); + + ff.on('exit', (code) => { + if (!res.writableEnded) res.end(); + if (code !== 0 && code !== null) console.error(`[ffmpeg] exited with code ${code}`); + }); + + req.on('close', () => { if (!ff.killed) ff.kill('SIGTERM'); }); +} + /** * GET /api/camera/stream * Proxies the MJPEG stream from Frigate (or the printer camera as fallback) * so the browser can display it without cross-origin issues. + * When the source URL uses the rtsp:// or rtsps:// scheme, ffmpeg is used to + * transcode the stream to MJPEG on the fly. */ app.get('/api/camera/stream', requireConfig, (req, res) => { if (!CAMERA_URL) { return res.status(503).json({ error: 'No camera source configured' }); } - const url = new URL(CAMERA_URL); - const transport = url.protocol === 'https:' ? https : http; + + const urlObj = new URL(CAMERA_URL); + if (urlObj.protocol === 'rtsp:' || urlObj.protocol === 'rtsps:') { + return streamRtspAsMjpeg(CAMERA_URL, req, res); + } + + const transport = urlObj.protocol === 'https:' ? https : http; const options = { - hostname: url.hostname, - port: url.port || (url.protocol === 'https:' ? 443 : 80), - path: url.pathname + url.search, + hostname: urlObj.hostname, + port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80), + path: urlObj.pathname + urlObj.search, method: 'GET', }; From 0318055871af549b55878835f6c6c43e71b143f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:09:40 +0000 Subject: [PATCH 31/70] =?UTF-8?q?Switch=20camera=20stream=20to=20Go2RTC=20?= =?UTF-8?q?MJPEG=20URL=20(rename=20frigate=5Furl=20=E2=86=92=20camera=5Fur?= =?UTF-8?q?l)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- flashforge-dashboard/config.yaml | 4 ++-- flashforge-dashboard/run.sh | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/flashforge-dashboard/config.yaml b/flashforge-dashboard/config.yaml index c86108a..c99a369 100644 --- a/flashforge-dashboard/config.yaml +++ b/flashforge-dashboard/config.yaml @@ -36,7 +36,7 @@ options: mqtt_username: "" mqtt_password: "" mqtt_base_topic: "flashforge" - frigate_url: "" + camera_url: "http://homeassistant.local:1984/api/stream.mjpeg?src=Stampante" schema: printer_ip: str @@ -48,6 +48,6 @@ schema: mqtt_username: str? mqtt_password: password? mqtt_base_topic: str - frigate_url: str? + camera_url: str? url: "https://github.com/MikManenti/flashforge-api-docs" diff --git a/flashforge-dashboard/run.sh b/flashforge-dashboard/run.sh index 16e189c..d1ce47e 100644 --- a/flashforge-dashboard/run.sh +++ b/flashforge-dashboard/run.sh @@ -18,7 +18,7 @@ export MQTT_PORT="$(bashio::config 'mqtt_port')" export MQTT_USERNAME="$(bashio::config 'mqtt_username')" export MQTT_PASSWORD="$(bashio::config 'mqtt_password')" export MQTT_BASE_TOPIC="$(bashio::config 'mqtt_base_topic')" -export FRIGATE_URL="$(bashio::config 'frigate_url')" +export FRIGATE_URL="$(bashio::config 'camera_url')" if bashio::config.is_empty 'printer_ip'; then bashio::log.warning "printer_ip is not configured. Set it in the add-on Configuration tab." From 30318d8193d902126dfeaf23541fff9c0c9ee683 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:23:43 +0000 Subject: [PATCH 32/70] feat: use HA camera entity instead of camera_url; add Frigate README example --- README.md | 22 ++++++ flashforge-dashboard/config.yaml | 4 +- flashforge-dashboard/run.sh | 3 +- flashforge-dashboard/server.js | 126 ++++--------------------------- 4 files changed, 42 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index 656fd84..f075c29 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,28 @@ Progetto non ufficiale, non affiliato con FlashForge o Home Assistant. - **Ingress Home Assistant**: da sidebar (come prima) - **HTTP diretto**: `http://:8099` +## Integrazione camera con Frigate + +Per visualizzare il feed della camera della stampante nel dashboard è possibile usare [Frigate](https://frigate.video/) con [go2rtc](https://github.com/AlexxIT/go2rtc). + +### Configurazione Frigate + +```yaml +go2rtc: + streams: + Stampante: + - "ffmpeg:http://IP_DELLA_STAMPANTE:8080/?action=stream" + +cameras: + Stampante: + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/Stampante +``` + +Dopo aver configurato Frigate, Home Assistant esporrà automaticamente un'entità camera (es. `camera.stampante`). +Inserisci il nome dell'entità nella configurazione dell'add-on nel campo **`camera_entity`** (es. `camera.stampante`). + ## Integrazione automatica Home Assistant (MQTT) L’add-on pubblica automaticamente sensori/switch via **MQTT Discovery**. diff --git a/flashforge-dashboard/config.yaml b/flashforge-dashboard/config.yaml index c99a369..c626300 100644 --- a/flashforge-dashboard/config.yaml +++ b/flashforge-dashboard/config.yaml @@ -36,7 +36,7 @@ options: mqtt_username: "" mqtt_password: "" mqtt_base_topic: "flashforge" - camera_url: "http://homeassistant.local:1984/api/stream.mjpeg?src=Stampante" + camera_entity: "" schema: printer_ip: str @@ -48,6 +48,6 @@ schema: mqtt_username: str? mqtt_password: password? mqtt_base_topic: str - camera_url: str? + camera_entity: str? url: "https://github.com/MikManenti/flashforge-api-docs" diff --git a/flashforge-dashboard/run.sh b/flashforge-dashboard/run.sh index d1ce47e..5c1efb0 100644 --- a/flashforge-dashboard/run.sh +++ b/flashforge-dashboard/run.sh @@ -18,7 +18,8 @@ export MQTT_PORT="$(bashio::config 'mqtt_port')" export MQTT_USERNAME="$(bashio::config 'mqtt_username')" export MQTT_PASSWORD="$(bashio::config 'mqtt_password')" export MQTT_BASE_TOPIC="$(bashio::config 'mqtt_base_topic')" -export FRIGATE_URL="$(bashio::config 'camera_url')" +export CAMERA_ENTITY="$(bashio::config 'camera_entity')" +export SUPERVISOR_TOKEN="${SUPERVISOR_TOKEN}" if bashio::config.is_empty 'printer_ip'; then bashio::log.warning "printer_ip is not configured. Set it in the add-on Configuration tab." diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 0bb087e..7aa38a2 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -9,8 +9,6 @@ const mqtt = require('mqtt'); const fs = require('fs'); const path = require('path'); const http = require('http'); -const https = require('https'); -const { spawn } = require('child_process'); const app = express(); const PORT = process.env.PORT || 8099; @@ -27,8 +25,8 @@ const KNOWN_MQTT_COMMAND_PAYLOADS = new Set([ const INGRESS_PATH = (process.env.INGRESS_PATH || '').replace(/\/$/, ''); const PRINTER_API = `http://${PRINTER_IP}:8898`; -const FRIGATE_URL = (process.env.FRIGATE_URL || '').trim(); -const CAMERA_URL = FRIGATE_URL || (PRINTER_IP ? `http://${PRINTER_IP}:8080/?action=stream` : null); +const CAMERA_ENTITY = (process.env.CAMERA_ENTITY || '').trim(); +const SUPERVISOR_TOKEN = process.env.SUPERVISOR_TOKEN || ''; const MQTT_ENABLED = parseBooleanEnv(process.env.MQTT_ENABLED, true); const MQTT_HOST = process.env.MQTT_HOST || 'core-mosquitto'; const MQTT_PORT = Number(process.env.MQTT_PORT || 1883); @@ -304,9 +302,6 @@ async function handleMqttCommand(topic, payloadRaw) { if (topic === `${MQTT_ROOT_TOPIC}/command/camera`) { const action = ['OPEN', 'ON', '1', 'TRUE'].includes(payload) ? 'open' : 'close'; - if (!FRIGATE_URL) { - await printerControl('streamCtrl_cmd', { action }); - } cameraSwitchState = action === 'open' ? 'ON' : 'OFF'; mqttPublish(`${MQTT_ROOT_TOPIC}/state/camera_switch`, cameraSwitchState, { retain: true }); return; @@ -555,103 +550,25 @@ app.post('/api/upload', requireConfig, upload.single('gcodeFile'), async (req, r res.status(printerRes.ok ? 200 : 502).json(result); }); -/** - * Transcodes an RTSP stream to HTTP MJPEG using ffmpeg. - * Spawns ffmpeg, reads raw JPEG frames from its stdout, and wraps them in a - * multipart/x-mixed-replace response so the browser tag can display it. - */ -function streamRtspAsMjpeg(rtspUrl, req, res) { - const BOUNDARY = 'mjpegstream'; - const MAX_PENDING = 4 * 1024 * 1024; // 4 MB – guard against a broken stream - - res.writeHead(200, { - 'Content-Type': `multipart/x-mixed-replace; boundary=${BOUNDARY}`, - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }); - - const ff = spawn('ffmpeg', [ - '-loglevel', 'error', - '-rtsp_transport', 'tcp', - '-i', rtspUrl, - '-vf', 'fps=10', - '-f', 'image2pipe', - '-vcodec', 'mjpeg', - '-q:v', '5', - 'pipe:1', - ]); - - let pending = Buffer.alloc(0); - const SOI = Buffer.from([0xff, 0xd8]); - const EOI = Buffer.from([0xff, 0xd9]); - - ff.stdout.on('data', (chunk) => { - pending = Buffer.concat([pending, chunk]); - - for (;;) { - const soi = pending.indexOf(SOI); - if (soi === -1) { - // Keep the last byte: the SOI marker might be split across chunks. - pending = pending.length > 0 ? pending.slice(-1) : Buffer.alloc(0); - break; - } - const eoi = pending.indexOf(EOI, soi + 2); - if (eoi === -1) { pending = pending.slice(soi); break; } - - const frame = pending.slice(soi, eoi + 2); - pending = pending.slice(eoi + 2); - - if (!res.writableEnded) { - 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 (pending.length > MAX_PENDING) pending = Buffer.alloc(0); - }); - - ff.stderr.on('data', (d) => console.error('[ffmpeg]', d.toString().trim())); - - ff.on('error', (err) => { - console.error('[ffmpeg] spawn error:', err.message); - if (!res.writableEnded) res.end(); - }); - - ff.on('exit', (code) => { - if (!res.writableEnded) res.end(); - if (code !== 0 && code !== null) console.error(`[ffmpeg] exited with code ${code}`); - }); - - req.on('close', () => { if (!ff.killed) ff.kill('SIGTERM'); }); -} - /** * GET /api/camera/stream - * Proxies the MJPEG stream from Frigate (or the printer camera as fallback) - * so the browser can display it without cross-origin issues. - * When the source URL uses the rtsp:// or rtsps:// scheme, ffmpeg is used to - * transcode the stream to MJPEG on the fly. + * Proxies the camera stream from the configured Home Assistant camera entity + * via the HA Supervisor API, so the browser can display it without CORS issues. */ app.get('/api/camera/stream', requireConfig, (req, res) => { - if (!CAMERA_URL) { - return res.status(503).json({ error: 'No camera source configured' }); - } - - const urlObj = new URL(CAMERA_URL); - if (urlObj.protocol === 'rtsp:' || urlObj.protocol === 'rtsps:') { - return streamRtspAsMjpeg(CAMERA_URL, req, res); + if (!CAMERA_ENTITY) { + return res.status(503).json({ error: 'No camera entity configured' }); } - const transport = urlObj.protocol === 'https:' ? https : http; const options = { - hostname: urlObj.hostname, - port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80), - path: urlObj.pathname + urlObj.search, + hostname: 'supervisor', + port: 80, + path: `/core/api/camera_proxy_stream/${CAMERA_ENTITY}`, method: 'GET', + headers: { Authorization: 'Bearer ' + SUPERVISOR_TOKEN }, }; - const proxyReq = transport.request(options, (proxyRes) => { + const proxyReq = http.request(options, (proxyRes) => { res.writeHead(proxyRes.statusCode, { 'Content-Type': proxyRes.headers['content-type'] || 'multipart/x-mixed-replace', 'Cache-Control': 'no-cache', @@ -673,24 +590,14 @@ app.get('/api/camera/stream', requireConfig, (req, res) => { /** * POST /api/camera * Body: { action: "open"|"close" } - * Enables or disables the camera stream. - * When Frigate is configured the printer's streamCtrl_cmd is skipped - * because Frigate manages the stream independently. + * Tracks camera switch state (the stream itself is provided by HA). */ app.post('/api/camera', requireConfig, async (req, res) => { const { action } = req.body; if (!action) return res.status(400).json({ error: 'action is required' }); - try { - let data = {}; - if (!FRIGATE_URL) { - data = await printerControl('streamCtrl_cmd', { action }); - } - cameraSwitchState = action === 'open' ? 'ON' : 'OFF'; - mqttPublish(`${MQTT_ROOT_TOPIC}/state/camera_switch`, cameraSwitchState, { retain: true }); - res.json(data); - } catch (err) { - res.status(502).json({ error: err.message }); - } + cameraSwitchState = action === 'open' ? 'ON' : 'OFF'; + mqttPublish(`${MQTT_ROOT_TOPIC}/state/camera_switch`, cameraSwitchState, { retain: true }); + res.json({}); }); // ── Config check endpoint ──────────────────────────────────────────────────── @@ -698,8 +605,7 @@ app.get('/api/config', (req, res) => { res.json({ configured: !!(PRINTER_IP && SERIAL_NUMBER && CHECK_CODE), printerIp: PRINTER_IP || null, - cameraUrl: CAMERA_URL ? `${INGRESS_PATH}/api/camera/stream` : null, - frigateEnabled: !!FRIGATE_URL, + cameraUrl: CAMERA_ENTITY ? `${INGRESS_PATH}/api/camera/stream` : null, ingressPath: INGRESS_PATH, }); }); From 773b07b57c8f33bf012b2d83045e1c9f4aeee689 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:24:41 +0000 Subject: [PATCH 33/70] fix: address code review comments on camera entity implementation --- flashforge-dashboard/config.yaml | 2 +- flashforge-dashboard/run.sh | 1 - flashforge-dashboard/server.js | 5 ++++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/flashforge-dashboard/config.yaml b/flashforge-dashboard/config.yaml index c626300..23918ae 100644 --- a/flashforge-dashboard/config.yaml +++ b/flashforge-dashboard/config.yaml @@ -36,7 +36,7 @@ options: mqtt_username: "" mqtt_password: "" mqtt_base_topic: "flashforge" - camera_entity: "" + camera_entity: "" # ID entità camera di Home Assistant, es. camera.stampante schema: printer_ip: str diff --git a/flashforge-dashboard/run.sh b/flashforge-dashboard/run.sh index 5c1efb0..2a95e54 100644 --- a/flashforge-dashboard/run.sh +++ b/flashforge-dashboard/run.sh @@ -19,7 +19,6 @@ export MQTT_USERNAME="$(bashio::config 'mqtt_username')" export MQTT_PASSWORD="$(bashio::config 'mqtt_password')" export MQTT_BASE_TOPIC="$(bashio::config 'mqtt_base_topic')" export CAMERA_ENTITY="$(bashio::config 'camera_entity')" -export SUPERVISOR_TOKEN="${SUPERVISOR_TOKEN}" if bashio::config.is_empty 'printer_ip'; then bashio::log.warning "printer_ip is not configured. Set it in the add-on Configuration tab." diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 7aa38a2..840cd56 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -557,7 +557,10 @@ app.post('/api/upload', requireConfig, upload.single('gcodeFile'), async (req, r */ app.get('/api/camera/stream', requireConfig, (req, res) => { if (!CAMERA_ENTITY) { - return res.status(503).json({ error: 'No camera entity configured' }); + return res.status(503).json({ error: 'Camera entity not configured. Please set camera_entity in add-on configuration.' }); + } + if (!SUPERVISOR_TOKEN) { + return res.status(503).json({ error: 'SUPERVISOR_TOKEN not available. Ensure the add-on is running inside Home Assistant.' }); } const options = { From a0a610163ed0939aa115ceaa2f09be33e6b87b20 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:01:35 +0000 Subject: [PATCH 34/70] feat: integrate go2rtc live video via WebSocket proxy and video-rtc component --- flashforge-dashboard/config.yaml | 4 + flashforge-dashboard/frontend/public/app.js | 48 +++++- .../frontend/public/index.html | 1 + .../frontend/public/style.css | 13 ++ flashforge-dashboard/run.sh | 5 + flashforge-dashboard/server.js | 139 +++++++++++++++++- 6 files changed, 204 insertions(+), 6 deletions(-) diff --git a/flashforge-dashboard/config.yaml b/flashforge-dashboard/config.yaml index 23918ae..6fb19da 100644 --- a/flashforge-dashboard/config.yaml +++ b/flashforge-dashboard/config.yaml @@ -37,6 +37,8 @@ options: mqtt_password: "" mqtt_base_topic: "flashforge" camera_entity: "" # ID entità camera di Home Assistant, es. camera.stampante + go2rtc_url: "http://a89bd424_go2rtc:1984" # URL interno dell'add-on go2rtc/Frigate + go2rtc_stream: "" # Nome dello stream in go2rtc, es. Stampante schema: printer_ip: str @@ -49,5 +51,7 @@ schema: mqtt_password: password? mqtt_base_topic: str camera_entity: str? + go2rtc_url: str? + go2rtc_stream: str? url: "https://github.com/MikManenti/flashforge-api-docs" diff --git a/flashforge-dashboard/frontend/public/app.js b/flashforge-dashboard/frontend/public/app.js index 99e4ecf..ca09157 100644 --- a/flashforge-dashboard/frontend/public/app.js +++ b/flashforge-dashboard/frontend/public/app.js @@ -11,6 +11,9 @@ function detectIngressFromPath(pathname) { const detectedIngress = detectIngressFromPath(window.location.pathname); const BASE = (window.INGRESS_PATH || detectedIngress || '').replace(/\/$/, ''); +/** Stream name injected by server.js when go2rtc is configured, otherwise null. */ +const GO2RTC_STREAM = window.GO2RTC_STREAM || null; + /* ── State ───────────────────────────────────────────────────────────────── */ let currentJobID = null; let currentStatus = null; @@ -20,6 +23,7 @@ let cameraStreamUrl = null; /* ── DOM refs ────────────────────────────────────────────────────────────── */ const badge = document.getElementById('status-badge'); +const cameraRtc = document.getElementById('camera-rtc'); const cameraImg = document.getElementById('camera-img'); const cameraPlaceholder = document.getElementById('camera-placeholder'); const btnCameraOn = document.getElementById('btn-camera-on'); @@ -169,14 +173,53 @@ function updateUI(d) { } /* ── Camera ──────────────────────────────────────────────────────────────── */ -function enableCamera() { + +/** + * Lazily load the go2rtc video-rtc.js custom element from our proxy endpoint. + * Returns a Promise that resolves to true when the element is registered, or + * false if loading failed. + */ +function loadGo2rtcClient() { + return new Promise((resolve) => { + if (!GO2RTC_STREAM) { resolve(false); return; } + if (customElements.get('video-rtc')) { resolve(true); return; } + const script = document.createElement('script'); + script.src = `${BASE}/api/go2rtc/client.js`; + script.onload = () => resolve(!!customElements.get('video-rtc')); + script.onerror = () => { + console.warn('go2rtc: could not load video-rtc.js – stream will not be available'); + resolve(false); + }; + document.head.appendChild(script); + }); +} + +async function enableCamera() { + if (GO2RTC_STREAM) { + const ready = await loadGo2rtcClient(); + if (ready) { + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + cameraRtc.src = `${wsProtocol}//${window.location.host}${BASE}/api/go2rtc/ws?src=${encodeURIComponent(GO2RTC_STREAM)}`; + cameraRtc.classList.add('active'); + cameraImg.src = ''; + cameraImg.classList.remove('active'); + cameraPlaceholder.classList.add('hidden'); + cameraActive = true; + return; + } + // Fall through to MJPEG if video-rtc.js failed to load + } if (!cameraStreamUrl) return; cameraImg.src = cameraStreamUrl; cameraImg.classList.add('active'); + cameraRtc.classList.remove('active'); cameraPlaceholder.classList.add('hidden'); cameraActive = true; } + function disableCamera() { + if (cameraRtc.src) cameraRtc.src = ''; + cameraRtc.classList.remove('active'); cameraImg.src = ''; cameraImg.classList.remove('active'); cameraPlaceholder.classList.remove('hidden'); @@ -184,10 +227,11 @@ function disableCamera() { } btnCameraOn.addEventListener('click', async () => { + if (!GO2RTC_STREAM && !cameraStreamUrl) return; try { await fetch(`${BASE}/api/camera`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'open' }) }); } catch (_) { /* ignore – try to show stream anyway */ } - enableCamera(); + await enableCamera(); }); btnCameraOff.addEventListener('click', async () => { diff --git a/flashforge-dashboard/frontend/public/index.html b/flashforge-dashboard/frontend/public/index.html index 5c28a5e..fc8c66f 100644 --- a/flashforge-dashboard/frontend/public/index.html +++ b/flashforge-dashboard/frontend/public/index.html @@ -20,6 +20,7 @@

🖨 FlashForge Dashboard

Camera

+ Camera stream
Camera non disponibile diff --git a/flashforge-dashboard/frontend/public/style.css b/flashforge-dashboard/frontend/public/style.css index 1de2d62..b1b7629 100644 --- a/flashforge-dashboard/frontend/public/style.css +++ b/flashforge-dashboard/frontend/public/style.css @@ -96,6 +96,19 @@ main { position: relative; margin-bottom: 12px; } +#camera-rtc { + width: 100%; + height: 100%; + display: none; + background: #000; +} +#camera-rtc.active { display: block; } +/* video element rendered inside video-rtc custom element */ +#camera-rtc video { + width: 100%; + height: 100%; + object-fit: contain; +} #camera-img { width: 100%; height: 100%; diff --git a/flashforge-dashboard/run.sh b/flashforge-dashboard/run.sh index 2a95e54..eebda71 100644 --- a/flashforge-dashboard/run.sh +++ b/flashforge-dashboard/run.sh @@ -19,6 +19,8 @@ export MQTT_USERNAME="$(bashio::config 'mqtt_username')" export MQTT_PASSWORD="$(bashio::config 'mqtt_password')" export MQTT_BASE_TOPIC="$(bashio::config 'mqtt_base_topic')" export CAMERA_ENTITY="$(bashio::config 'camera_entity')" +export GO2RTC_URL="$(bashio::config 'go2rtc_url')" +export GO2RTC_STREAM="$(bashio::config 'go2rtc_stream')" if bashio::config.is_empty 'printer_ip'; then bashio::log.warning "printer_ip is not configured. Set it in the add-on Configuration tab." @@ -34,5 +36,8 @@ bashio::log.info "Printer IP: ${PRINTER_IP}" bashio::log.info "Listening on port ${PORT}" bashio::log.info "MQTT enabled: ${MQTT_ENABLED}" bashio::log.info "MQTT broker: ${MQTT_HOST}:${MQTT_PORT}" +if [ -n "${GO2RTC_STREAM}" ]; then + bashio::log.info "go2rtc URL: ${GO2RTC_URL} / stream: ${GO2RTC_STREAM}" +fi exec node /app/server.js diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 840cd56..4847631 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -9,6 +9,7 @@ const mqtt = require('mqtt'); const fs = require('fs'); const path = require('path'); const http = require('http'); +const net = require('net'); const app = express(); const PORT = process.env.PORT || 8099; @@ -27,6 +28,8 @@ const INGRESS_PATH = (process.env.INGRESS_PATH || '').replace(/\/$/, ''); const PRINTER_API = `http://${PRINTER_IP}:8898`; const CAMERA_ENTITY = (process.env.CAMERA_ENTITY || '').trim(); const SUPERVISOR_TOKEN = process.env.SUPERVISOR_TOKEN || ''; +const GO2RTC_URL = (process.env.GO2RTC_URL || '').replace(/\/$/, ''); +const GO2RTC_STREAM = (process.env.GO2RTC_STREAM || '').trim(); const MQTT_ENABLED = parseBooleanEnv(process.env.MQTT_ENABLED, true); const MQTT_HOST = process.env.MQTT_HOST || 'core-mosquitto'; const MQTT_PORT = Number(process.env.MQTT_PORT || 1883); @@ -603,12 +606,62 @@ app.post('/api/camera', requireConfig, async (req, res) => { res.json({}); }); +// ── go2rtc integration ─────────────────────────────────────────────────────── + +/** Server-side cache for video-rtc.js to avoid fetching it on every page load. */ +let cachedVideoRtcJs = null; +let cachedVideoRtcJsAt = 0; +const VIDEO_RTC_CACHE_TTL_MS = 3600000; // 1 hour + +/** + * GET /api/go2rtc/client.js + * Proxies the video-rtc.js player component from the local go2rtc add-on so + * the browser can load it from the same origin (no CORS issues). + * The response is cached server-side for one hour. + */ +app.get('/api/go2rtc/client.js', async (req, res) => { + if (!GO2RTC_URL) { + return res.status(503).send('// go2rtc_url not configured\n'); + } + + const now = Date.now(); + if (cachedVideoRtcJs && (now - cachedVideoRtcJsAt) < VIDEO_RTC_CACHE_TTL_MS) { + res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); + res.setHeader('Cache-Control', 'max-age=3600'); + return res.send(cachedVideoRtcJs); + } + + try { + const upstream = await fetch(`${GO2RTC_URL}/video-rtc.js`); + if (!upstream.ok) { + if (cachedVideoRtcJs) { + res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); + return res.send(cachedVideoRtcJs); // serve stale on error + } + return res.status(upstream.status).send(`// go2rtc returned ${upstream.status}\n`); + } + cachedVideoRtcJs = await upstream.text(); + cachedVideoRtcJsAt = now; + res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); + res.setHeader('Cache-Control', 'max-age=3600'); + res.send(cachedVideoRtcJs); + } catch (err) { + if (cachedVideoRtcJs) { + res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); + return res.send(cachedVideoRtcJs); // serve stale on error + } + res.status(502).send(`// go2rtc client error: ${err.message}\n`); + } +}); + // ── Config check endpoint ──────────────────────────────────────────────────── app.get('/api/config', (req, res) => { res.json({ configured: !!(PRINTER_IP && SERIAL_NUMBER && CHECK_CODE), printerIp: PRINTER_IP || null, cameraUrl: CAMERA_ENTITY ? `${INGRESS_PATH}/api/camera/stream` : null, + go2rtcConfigured: !!(GO2RTC_URL && GO2RTC_STREAM), + go2rtcStream: GO2RTC_STREAM || null, ingressPath: INGRESS_PATH, }); }); @@ -622,8 +675,11 @@ const INDEX_HTML_PATH = path.join(__dirname, 'frontend', 'public', 'index.html') const indexHtmlBase = fs.readFileSync(INDEX_HTML_PATH, 'utf8'); function serveIndex(req, res) { - const script = `\n`; - const html = indexHtmlBase.replace('', ` ${script}`); + let headInject = ``; + if (GO2RTC_URL && GO2RTC_STREAM) { + headInject += `\n `; + } + const html = indexHtmlBase.replace('', ` ${headInject}\n`); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.send(html); } @@ -631,7 +687,7 @@ function serveIndex(req, res) { app.get('*', serveIndex); // ── Start ──────────────────────────────────────────────────────────────────── -app.listen(PORT, () => { +const server = app.listen(PORT, () => { console.log(`FlashForge Dashboard (HA add-on) running on port ${PORT}`); console.log(`Ingress path: ${INGRESS_PATH || '(none)'}`); console.log(`Direct HTTP URL: http://:${PORT}`); @@ -646,6 +702,79 @@ app.listen(PORT, () => { } }); +/** + * WebSocket proxy for go2rtc. + * The browser connects (via HA Ingress) to ws://.../api/go2rtc/ws?src={stream}. + * This handler relays those frames to go2rtc's ws://GO2RTC_URL/api/ws?src={stream} + * over plain TCP (no CORS, no Mixed-Content, works inside Docker networks). + */ +server.on('upgrade', (req, socket, head) => { + let reqUrl; + try { reqUrl = new URL(req.url, 'http://localhost'); } catch (_) { + socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); + socket.destroy(); + return; + } + + if (!reqUrl.pathname.startsWith('/api/go2rtc/ws') || !GO2RTC_URL) { + socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); + socket.destroy(); + return; + } + + let go2rtcBase; + try { go2rtcBase = new URL(GO2RTC_URL); } catch (_) { + socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); + socket.destroy(); + return; + } + + const stream = reqUrl.searchParams.get('src') || GO2RTC_STREAM; + if (!stream) { + socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); + socket.destroy(); + return; + } + + // Forward any extra query params (e.g. media preferences from video-rtc.js) + const extraParams = []; + reqUrl.searchParams.forEach((val, key) => { + if (key !== 'src') extraParams.push(`${encodeURIComponent(key)}=${encodeURIComponent(val)}`); + }); + let upstreamPath = `/api/ws?src=${encodeURIComponent(stream)}`; + if (extraParams.length) upstreamPath += `&${extraParams.join('&')}`; + + const go2rtcHost = go2rtcBase.hostname; + const go2rtcPort = parseInt(go2rtcBase.port, 10) || (go2rtcBase.protocol === 'https:' ? 443 : 80); + + const proxySocket = net.connect(go2rtcPort, go2rtcHost, () => { + const lines = [ + `GET ${upstreamPath} HTTP/1.1`, + `Host: ${go2rtcHost}:${go2rtcPort}`, + `Upgrade: websocket`, + `Connection: Upgrade`, + `Sec-WebSocket-Version: ${req.headers['sec-websocket-version'] || '13'}`, + `Sec-WebSocket-Key: ${req.headers['sec-websocket-key'] || ''}`, + ]; + if (req.headers['sec-websocket-protocol']) { + lines.push(`Sec-WebSocket-Protocol: ${req.headers['sec-websocket-protocol']}`); + } + lines.push('', ''); + proxySocket.write(lines.join('\r\n')); + }); + + proxySocket.on('error', (err) => { + console.warn(`go2rtc WebSocket proxy error: ${err.message}`); + if (!socket.destroyed) socket.destroy(); + }); + socket.on('error', () => { if (!proxySocket.destroyed) proxySocket.destroy(); }); + socket.on('close', () => { if (!proxySocket.destroyed) proxySocket.destroy(); }); + proxySocket.on('close', () => { if (!socket.destroyed) socket.destroy(); }); + + proxySocket.pipe(socket); + socket.pipe(proxySocket); +}); + function shutdown() { if (mqttPollingTimer) { clearInterval(mqttPollingTimer); @@ -660,7 +789,9 @@ function shutdown() { console.warn(`MQTT shutdown warning: ${err.message}`); } } - process.exit(0); + server.close(() => process.exit(0)); + // Ensure we exit even if server.close hangs (e.g. open WebSocket connections) + setTimeout(() => process.exit(0), 3000).unref(); } process.on('SIGTERM', shutdown); From ce2558ba1b5868a39ca96cf7ef94f8e5b72a0e47 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:03:04 +0000 Subject: [PATCH 35/70] fix: address code review feedback on WebSocket proxy and URL construction --- flashforge-dashboard/frontend/public/app.js | 4 +++- flashforge-dashboard/server.js | 14 +++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/flashforge-dashboard/frontend/public/app.js b/flashforge-dashboard/frontend/public/app.js index ca09157..6dd5ded 100644 --- a/flashforge-dashboard/frontend/public/app.js +++ b/flashforge-dashboard/frontend/public/app.js @@ -199,7 +199,9 @@ async function enableCamera() { const ready = await loadGo2rtcClient(); if (ready) { const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - cameraRtc.src = `${wsProtocol}//${window.location.host}${BASE}/api/go2rtc/ws?src=${encodeURIComponent(GO2RTC_STREAM)}`; + const wsUrl = new URL(`${BASE}/api/go2rtc/ws`, `${wsProtocol}//${window.location.host}`); + wsUrl.searchParams.set('src', GO2RTC_STREAM); + cameraRtc.src = wsUrl.toString(); cameraRtc.classList.add('active'); cameraImg.src = ''; cameraImg.classList.remove('active'); diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 4847631..85d902a 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -736,6 +736,14 @@ server.on('upgrade', (req, socket, head) => { return; } + // Sec-WebSocket-Key is mandatory per RFC 6455; reject early if absent. + const wsKey = req.headers['sec-websocket-key']; + if (!wsKey) { + socket.write('HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n'); + socket.destroy(); + return; + } + // Forward any extra query params (e.g. media preferences from video-rtc.js) const extraParams = []; reqUrl.searchParams.forEach((val, key) => { @@ -754,7 +762,7 @@ server.on('upgrade', (req, socket, head) => { `Upgrade: websocket`, `Connection: Upgrade`, `Sec-WebSocket-Version: ${req.headers['sec-websocket-version'] || '13'}`, - `Sec-WebSocket-Key: ${req.headers['sec-websocket-key'] || ''}`, + `Sec-WebSocket-Key: ${wsKey}`, ]; if (req.headers['sec-websocket-protocol']) { lines.push(`Sec-WebSocket-Protocol: ${req.headers['sec-websocket-protocol']}`); @@ -771,6 +779,10 @@ server.on('upgrade', (req, socket, head) => { socket.on('close', () => { if (!proxySocket.destroyed) proxySocket.destroy(); }); proxySocket.on('close', () => { if (!socket.destroyed) socket.destroy(); }); + // Transparent TCP tunnel: all bytes (including the HTTP 101 upgrade response + // from go2rtc) are forwarded verbatim to the browser. The browser validates + // the handshake; if go2rtc rejects (e.g. returns 4xx), the browser receives + // that HTTP response and correctly fails the WebSocket connection. proxySocket.pipe(socket); socket.pipe(proxySocket); }); From 35b1e33d3050dc8ba843f37e98319d67780dd813 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:21:57 +0000 Subject: [PATCH 36/70] fix: WebSocket proxy default URL, remove camera entity MJPEG integration - Default GO2RTC_URL to http://a89bd424_go2rtc:1984 so the WS proxy works without explicit env configuration - Default GO2RTC_STREAM to 'Stampante' - Remove CAMERA_ENTITY / SUPERVISOR_TOKEN constants and the /api/camera/stream MJPEG proxy route - Drop cameraUrl from /api/config response - Remove MJPEG fallback path in app.js enableCamera() - Remove cameraStreamUrl variable and cfg.cameraUrl read in boot - Remove camera_entity from config.yaml options/schema; default go2rtc_stream to 'Stampante' - Remove CAMERA_ENTITY export from run.sh --- flashforge-dashboard/config.yaml | 4 +- flashforge-dashboard/frontend/public/app.js | 16 +------ flashforge-dashboard/run.sh | 1 - flashforge-dashboard/server.js | 47 +-------------------- 4 files changed, 5 insertions(+), 63 deletions(-) diff --git a/flashforge-dashboard/config.yaml b/flashforge-dashboard/config.yaml index 6fb19da..e9f6044 100644 --- a/flashforge-dashboard/config.yaml +++ b/flashforge-dashboard/config.yaml @@ -36,9 +36,8 @@ options: mqtt_username: "" mqtt_password: "" mqtt_base_topic: "flashforge" - camera_entity: "" # ID entità camera di Home Assistant, es. camera.stampante go2rtc_url: "http://a89bd424_go2rtc:1984" # URL interno dell'add-on go2rtc/Frigate - go2rtc_stream: "" # Nome dello stream in go2rtc, es. Stampante + go2rtc_stream: "Stampante" # Nome dello stream in go2rtc schema: printer_ip: str @@ -50,7 +49,6 @@ schema: mqtt_username: str? mqtt_password: password? mqtt_base_topic: str - camera_entity: str? go2rtc_url: str? go2rtc_stream: str? diff --git a/flashforge-dashboard/frontend/public/app.js b/flashforge-dashboard/frontend/public/app.js index 6dd5ded..eff767a 100644 --- a/flashforge-dashboard/frontend/public/app.js +++ b/flashforge-dashboard/frontend/public/app.js @@ -19,7 +19,6 @@ let currentJobID = null; let currentStatus = null; let pollingTimer = null; let cameraActive = false; -let cameraStreamUrl = null; /* ── DOM refs ────────────────────────────────────────────────────────────── */ const badge = document.getElementById('status-badge'); @@ -209,14 +208,8 @@ async function enableCamera() { cameraActive = true; return; } - // Fall through to MJPEG if video-rtc.js failed to load } - if (!cameraStreamUrl) return; - cameraImg.src = cameraStreamUrl; - cameraImg.classList.add('active'); - cameraRtc.classList.remove('active'); - cameraPlaceholder.classList.add('hidden'); - cameraActive = true; + cameraPlaceholder.classList.remove('hidden'); } function disableCamera() { @@ -229,7 +222,7 @@ function disableCamera() { } btnCameraOn.addEventListener('click', async () => { - if (!GO2RTC_STREAM && !cameraStreamUrl) return; + if (!GO2RTC_STREAM) return; try { await fetch(`${BASE}/api/camera`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'open' }) }); } catch (_) { /* ignore – try to show stream anyway */ } @@ -243,10 +236,6 @@ btnCameraOff.addEventListener('click', async () => { } catch (_) { /* ignore */ } }); -cameraImg.addEventListener('error', () => { - disableCamera(); -}); - /* ── Print controls ──────────────────────────────────────────────────────── */ async function sendControl(action) { const actionMap = { @@ -491,7 +480,6 @@ function setUploadPct(pct) { // Check configuration try { const cfg = await fetch(`${BASE}/api/config`).then(r => r.json()); - if (cfg.cameraUrl) cameraStreamUrl = cfg.cameraUrl; if (!cfg.configured) { badge.textContent = 'NON CONFIGURATO'; badge.className = 'badge badge--error'; diff --git a/flashforge-dashboard/run.sh b/flashforge-dashboard/run.sh index eebda71..7772aeb 100644 --- a/flashforge-dashboard/run.sh +++ b/flashforge-dashboard/run.sh @@ -18,7 +18,6 @@ export MQTT_PORT="$(bashio::config 'mqtt_port')" export MQTT_USERNAME="$(bashio::config 'mqtt_username')" export MQTT_PASSWORD="$(bashio::config 'mqtt_password')" export MQTT_BASE_TOPIC="$(bashio::config 'mqtt_base_topic')" -export CAMERA_ENTITY="$(bashio::config 'camera_entity')" export GO2RTC_URL="$(bashio::config 'go2rtc_url')" export GO2RTC_STREAM="$(bashio::config 'go2rtc_stream')" diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 85d902a..b0bd73f 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -26,10 +26,8 @@ const KNOWN_MQTT_COMMAND_PAYLOADS = new Set([ const INGRESS_PATH = (process.env.INGRESS_PATH || '').replace(/\/$/, ''); const PRINTER_API = `http://${PRINTER_IP}:8898`; -const CAMERA_ENTITY = (process.env.CAMERA_ENTITY || '').trim(); -const SUPERVISOR_TOKEN = process.env.SUPERVISOR_TOKEN || ''; -const GO2RTC_URL = (process.env.GO2RTC_URL || '').replace(/\/$/, ''); -const GO2RTC_STREAM = (process.env.GO2RTC_STREAM || '').trim(); +const GO2RTC_URL = (process.env.GO2RTC_URL || 'http://a89bd424_go2rtc:1984').replace(/\/$/, ''); +const GO2RTC_STREAM = (process.env.GO2RTC_STREAM || 'Stampante').trim(); const MQTT_ENABLED = parseBooleanEnv(process.env.MQTT_ENABLED, true); const MQTT_HOST = process.env.MQTT_HOST || 'core-mosquitto'; const MQTT_PORT = Number(process.env.MQTT_PORT || 1883); @@ -553,46 +551,6 @@ app.post('/api/upload', requireConfig, upload.single('gcodeFile'), async (req, r res.status(printerRes.ok ? 200 : 502).json(result); }); -/** - * GET /api/camera/stream - * Proxies the camera stream from the configured Home Assistant camera entity - * via the HA Supervisor API, so the browser can display it without CORS issues. - */ -app.get('/api/camera/stream', requireConfig, (req, res) => { - if (!CAMERA_ENTITY) { - return res.status(503).json({ error: 'Camera entity not configured. Please set camera_entity in add-on configuration.' }); - } - if (!SUPERVISOR_TOKEN) { - return res.status(503).json({ error: 'SUPERVISOR_TOKEN not available. Ensure the add-on is running inside Home Assistant.' }); - } - - const options = { - hostname: 'supervisor', - port: 80, - path: `/core/api/camera_proxy_stream/${CAMERA_ENTITY}`, - method: 'GET', - headers: { Authorization: 'Bearer ' + SUPERVISOR_TOKEN }, - }; - - const proxyReq = http.request(options, (proxyRes) => { - res.writeHead(proxyRes.statusCode, { - 'Content-Type': proxyRes.headers['content-type'] || 'multipart/x-mixed-replace', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }); - proxyRes.pipe(res); - }); - - proxyReq.on('error', (err) => { - if (!res.headersSent) { - res.status(502).json({ error: `Camera stream error: ${err.message}` }); - } - }); - - req.on('close', () => proxyReq.destroy()); - proxyReq.end(); -}); - /** * POST /api/camera * Body: { action: "open"|"close" } @@ -659,7 +617,6 @@ app.get('/api/config', (req, res) => { res.json({ configured: !!(PRINTER_IP && SERIAL_NUMBER && CHECK_CODE), printerIp: PRINTER_IP || null, - cameraUrl: CAMERA_ENTITY ? `${INGRESS_PATH}/api/camera/stream` : null, go2rtcConfigured: !!(GO2RTC_URL && GO2RTC_STREAM), go2rtcStream: GO2RTC_STREAM || null, ingressPath: INGRESS_PATH, From a39c369c93fe81745acae3facf58c2f63d8800ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:23:32 +0000 Subject: [PATCH 37/70] fix: remove dead cameraActive variable and add comment to enableCamera fallback --- flashforge-dashboard/frontend/public/app.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/flashforge-dashboard/frontend/public/app.js b/flashforge-dashboard/frontend/public/app.js index eff767a..ee6b28b 100644 --- a/flashforge-dashboard/frontend/public/app.js +++ b/flashforge-dashboard/frontend/public/app.js @@ -18,7 +18,6 @@ const GO2RTC_STREAM = window.GO2RTC_STREAM || null; let currentJobID = null; let currentStatus = null; let pollingTimer = null; -let cameraActive = false; /* ── DOM refs ────────────────────────────────────────────────────────────── */ const badge = document.getElementById('status-badge'); @@ -205,10 +204,10 @@ async function enableCamera() { cameraImg.src = ''; cameraImg.classList.remove('active'); cameraPlaceholder.classList.add('hidden'); - cameraActive = true; return; } } + // go2rtc not available or failed to load — show placeholder cameraPlaceholder.classList.remove('hidden'); } @@ -218,7 +217,6 @@ function disableCamera() { cameraImg.src = ''; cameraImg.classList.remove('active'); cameraPlaceholder.classList.remove('hidden'); - cameraActive = false; } btnCameraOn.addEventListener('click', async () => { From b7df4ce85ca82f2a6b664441ed777cefdf3a45fb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:37:13 +0000 Subject: [PATCH 38/70] Fix ingress-aware go2rtc websocket upgrade proxy --- flashforge-dashboard/server.js | 43 ++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index b0bd73f..4218c9e 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -673,7 +673,15 @@ server.on('upgrade', (req, socket, head) => { return; } - if (!reqUrl.pathname.startsWith('/api/go2rtc/ws') || !GO2RTC_URL) { + const normalizedPath = (() => { + const ingressPrefix = (INGRESS_PATH || '').trim().replace(/\/+$/, ''); + if (ingressPrefix && reqUrl.pathname.startsWith(`${ingressPrefix}/`)) { + return reqUrl.pathname.slice(ingressPrefix.length); + } + return reqUrl.pathname; + })(); + + if (!normalizedPath.endsWith('/api/go2rtc/ws') || !GO2RTC_URL) { socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); socket.destroy(); return; @@ -701,31 +709,30 @@ server.on('upgrade', (req, socket, head) => { return; } - // Forward any extra query params (e.g. media preferences from video-rtc.js) - const extraParams = []; - reqUrl.searchParams.forEach((val, key) => { - if (key !== 'src') extraParams.push(`${encodeURIComponent(key)}=${encodeURIComponent(val)}`); - }); - let upstreamPath = `/api/ws?src=${encodeURIComponent(stream)}`; - if (extraParams.length) upstreamPath += `&${extraParams.join('&')}`; + const upstreamPath = `/api/ws?src=${encodeURIComponent(stream)}`; const go2rtcHost = go2rtcBase.hostname; const go2rtcPort = parseInt(go2rtcBase.port, 10) || (go2rtcBase.protocol === 'https:' ? 443 : 80); + const upstreamHost = `${go2rtcHost}:${go2rtcPort}`; const proxySocket = net.connect(go2rtcPort, go2rtcHost, () => { - const lines = [ - `GET ${upstreamPath} HTTP/1.1`, - `Host: ${go2rtcHost}:${go2rtcPort}`, - `Upgrade: websocket`, - `Connection: Upgrade`, - `Sec-WebSocket-Version: ${req.headers['sec-websocket-version'] || '13'}`, - `Sec-WebSocket-Key: ${wsKey}`, - ]; - if (req.headers['sec-websocket-protocol']) { - lines.push(`Sec-WebSocket-Protocol: ${req.headers['sec-websocket-protocol']}`); + const upstreamHeaders = { + ...req.headers, + host: upstreamHost, + }; + + const lines = [`GET ${upstreamPath} HTTP/1.1`]; + for (const [name, value] of Object.entries(upstreamHeaders)) { + if (typeof value === 'undefined') continue; + if (Array.isArray(value)) { + for (const item of value) lines.push(`${name}: ${item}`); + } else { + lines.push(`${name}: ${value}`); + } } lines.push('', ''); proxySocket.write(lines.join('\r\n')); + if (head && head.length) proxySocket.write(head); }); proxySocket.on('error', (err) => { From 020c595d330bfecd59577bea615054c01b7105fd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:52:29 +0000 Subject: [PATCH 39/70] Fix go2rtc component and websocket path checks --- flashforge-dashboard/frontend/public/app.js | 8 +++--- .../frontend/public/index.html | 25 ++++++++++++++++++- flashforge-dashboard/server.js | 2 +- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/flashforge-dashboard/frontend/public/app.js b/flashforge-dashboard/frontend/public/app.js index ee6b28b..3f0d51d 100644 --- a/flashforge-dashboard/frontend/public/app.js +++ b/flashforge-dashboard/frontend/public/app.js @@ -173,19 +173,19 @@ function updateUI(d) { /* ── Camera ──────────────────────────────────────────────────────────────── */ /** - * Lazily load the go2rtc video-rtc.js custom element from our proxy endpoint. + * Lazily load the go2rtc custom element client from our proxy endpoint. * Returns a Promise that resolves to true when the element is registered, or * false if loading failed. */ function loadGo2rtcClient() { return new Promise((resolve) => { if (!GO2RTC_STREAM) { resolve(false); return; } - if (customElements.get('video-rtc')) { resolve(true); return; } + if (customElements.get('video-stream')) { resolve(true); return; } const script = document.createElement('script'); script.src = `${BASE}/api/go2rtc/client.js`; - script.onload = () => resolve(!!customElements.get('video-rtc')); + script.onload = () => resolve(!!customElements.get('video-stream')); script.onerror = () => { - console.warn('go2rtc: could not load video-rtc.js – stream will not be available'); + console.warn('go2rtc: could not load client.js – stream will not be available'); resolve(false); }; document.head.appendChild(script); diff --git a/flashforge-dashboard/frontend/public/index.html b/flashforge-dashboard/frontend/public/index.html index fc8c66f..04a18d2 100644 --- a/flashforge-dashboard/frontend/public/index.html +++ b/flashforge-dashboard/frontend/public/index.html @@ -5,6 +5,15 @@ FlashForge Dashboard +
@@ -20,7 +29,7 @@

🖨 FlashForge Dashboard

Camera

- + Camera stream
Camera non disponibile @@ -30,6 +39,20 @@

Camera

+
diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 4218c9e..879f956 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -681,7 +681,7 @@ server.on('upgrade', (req, socket, head) => { return reqUrl.pathname; })(); - if (!normalizedPath.endsWith('/api/go2rtc/ws') || !GO2RTC_URL) { + if (!normalizedPath.includes('/api/go2rtc/ws') || !GO2RTC_URL) { socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); socket.destroy(); return; From 88fa341d3dda26caa2d4f31152c3e7d61e89a3ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:54:35 +0000 Subject: [PATCH 40/70] Avoid duplicate go2rtc client loading --- flashforge-dashboard/frontend/public/app.js | 36 ++++++++++++++----- .../frontend/public/index.html | 1 + 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/flashforge-dashboard/frontend/public/app.js b/flashforge-dashboard/frontend/public/app.js index 3f0d51d..b5b7917 100644 --- a/flashforge-dashboard/frontend/public/app.js +++ b/flashforge-dashboard/frontend/public/app.js @@ -18,6 +18,7 @@ const GO2RTC_STREAM = window.GO2RTC_STREAM || null; let currentJobID = null; let currentStatus = null; let pollingTimer = null; +let go2rtcClientLoadPromise = null; /* ── DOM refs ────────────────────────────────────────────────────────────── */ const badge = document.getElementById('status-badge'); @@ -178,18 +179,35 @@ function updateUI(d) { * false if loading failed. */ function loadGo2rtcClient() { - return new Promise((resolve) => { - if (!GO2RTC_STREAM) { resolve(false); return; } - if (customElements.get('video-stream')) { resolve(true); return; } - const script = document.createElement('script'); - script.src = `${BASE}/api/go2rtc/client.js`; - script.onload = () => resolve(!!customElements.get('video-stream')); - script.onerror = () => { + if (!GO2RTC_STREAM) return Promise.resolve(false); + if (customElements.get('video-stream')) return Promise.resolve(true); + if (go2rtcClientLoadPromise) return go2rtcClientLoadPromise; + + go2rtcClientLoadPromise = new Promise((resolve) => { + const finish = () => resolve(!!customElements.get('video-stream')); + const existingScript = document.querySelector('script[data-go2rtc-client="1"]'); + const script = existingScript || document.createElement('script'); + + if (!existingScript) { + script.dataset.go2rtcClient = '1'; + script.src = `${BASE}/api/go2rtc/client.js`; + document.head.appendChild(script); + } + + script.addEventListener('load', finish, { once: true }); + script.addEventListener('error', () => { console.warn('go2rtc: could not load client.js – stream will not be available'); resolve(false); - }; - document.head.appendChild(script); + }, { once: true }); + + if (script.readyState === 'complete' || customElements.get('video-stream')) { + finish(); + } + }).finally(() => { + go2rtcClientLoadPromise = null; }); + + return go2rtcClientLoadPromise; } async function enableCamera() { diff --git a/flashforge-dashboard/frontend/public/index.html b/flashforge-dashboard/frontend/public/index.html index 04a18d2..4e38590 100644 --- a/flashforge-dashboard/frontend/public/index.html +++ b/flashforge-dashboard/frontend/public/index.html @@ -9,6 +9,7 @@ document.addEventListener('DOMContentLoaded', () => { const ingressPath = window.INGRESS_PATH || ''; const go2rtcClient = document.createElement('script'); + go2rtcClient.dataset.go2rtcClient = '1'; go2rtcClient.src = `${ingressPath}/api/go2rtc/client.js`; go2rtcClient.defer = true; document.head.appendChild(go2rtcClient); From 4c784699df301562fa6ba22341cbe6318de48ea8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:07:36 +0000 Subject: [PATCH 41/70] fix: fix go2rtc camera streaming under HA Ingress - server.js: replace normalizedPath INGRESS_PATH prefix-stripping with direct pathname.includes() check; simplify WebSocket header forwarding - index.html: replace dynamic client.js injection with static defer script; remove redundant inline camera script - app.js: replace async loadGo2rtcClient + enableCamera with sync initCamera; auto-call initCamera at boot --- flashforge-dashboard/frontend/public/app.js | 74 ++++++------------- .../frontend/public/index.html | 28 +------ flashforge-dashboard/server.js | 62 ++++++---------- 3 files changed, 48 insertions(+), 116 deletions(-) diff --git a/flashforge-dashboard/frontend/public/app.js b/flashforge-dashboard/frontend/public/app.js index b5b7917..bdc0b1a 100644 --- a/flashforge-dashboard/frontend/public/app.js +++ b/flashforge-dashboard/frontend/public/app.js @@ -18,7 +18,6 @@ const GO2RTC_STREAM = window.GO2RTC_STREAM || null; let currentJobID = null; let currentStatus = null; let pollingTimer = null; -let go2rtcClientLoadPromise = null; /* ── DOM refs ────────────────────────────────────────────────────────────── */ const badge = document.getElementById('status-badge'); @@ -174,59 +173,29 @@ function updateUI(d) { /* ── Camera ──────────────────────────────────────────────────────────────── */ /** - * Lazily load the go2rtc custom element client from our proxy endpoint. - * Returns a Promise that resolves to true when the element is registered, or - * false if loading failed. + * Initialise (or re-initialise) the camera stream. + * client.js is loaded statically in index.html with `defer`, so the + * `` custom element will be registered before or shortly after + * this runs. Setting `src` before registration is safe: the browser queues the + * attribute and processes it once the element is upgraded. */ -function loadGo2rtcClient() { - if (!GO2RTC_STREAM) return Promise.resolve(false); - if (customElements.get('video-stream')) return Promise.resolve(true); - if (go2rtcClientLoadPromise) return go2rtcClientLoadPromise; - - go2rtcClientLoadPromise = new Promise((resolve) => { - const finish = () => resolve(!!customElements.get('video-stream')); - const existingScript = document.querySelector('script[data-go2rtc-client="1"]'); - const script = existingScript || document.createElement('script'); - - if (!existingScript) { - script.dataset.go2rtcClient = '1'; - script.src = `${BASE}/api/go2rtc/client.js`; - document.head.appendChild(script); - } - - script.addEventListener('load', finish, { once: true }); - script.addEventListener('error', () => { - console.warn('go2rtc: could not load client.js – stream will not be available'); - resolve(false); - }, { once: true }); - - if (script.readyState === 'complete' || customElements.get('video-stream')) { - finish(); - } - }).finally(() => { - go2rtcClientLoadPromise = null; - }); +function initCamera() { + if (!GO2RTC_STREAM) { + console.log('Camera component skipped: GO2RTC_STREAM is not configured'); + cameraPlaceholder.classList.remove('hidden'); + cameraRtc.classList.remove('active'); + cameraImg.classList.remove('active'); + return; + } - return go2rtcClientLoadPromise; -} + cameraPlaceholder.classList.add('hidden'); + cameraImg.classList.remove('active'); + cameraRtc.classList.add('active'); -async function enableCamera() { - if (GO2RTC_STREAM) { - const ready = await loadGo2rtcClient(); - if (ready) { - const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = new URL(`${BASE}/api/go2rtc/ws`, `${wsProtocol}//${window.location.host}`); - wsUrl.searchParams.set('src', GO2RTC_STREAM); - cameraRtc.src = wsUrl.toString(); - cameraRtc.classList.add('active'); - cameraImg.src = ''; - cameraImg.classList.remove('active'); - cameraPlaceholder.classList.add('hidden'); - return; - } - } - // go2rtc not available or failed to load — show placeholder - cameraPlaceholder.classList.remove('hidden'); + const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${wsProto}//${window.location.host}${BASE}/api/go2rtc/ws?src=${encodeURIComponent(GO2RTC_STREAM)}`; + console.log(`Initializing video-stream target: ${wsUrl}`); + cameraRtc.setAttribute('src', wsUrl); } function disableCamera() { @@ -242,7 +211,7 @@ btnCameraOn.addEventListener('click', async () => { try { await fetch(`${BASE}/api/camera`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'open' }) }); } catch (_) { /* ignore – try to show stream anyway */ } - await enableCamera(); + initCamera(); }); btnCameraOff.addEventListener('click', async () => { @@ -510,6 +479,7 @@ function setUploadPct(pct) { } } catch (_) { /* proceed anyway */ } + initCamera(); await fetchStatus(); pollingTimer = setInterval(fetchStatus, 4000); })(); diff --git a/flashforge-dashboard/frontend/public/index.html b/flashforge-dashboard/frontend/public/index.html index 4e38590..607695c 100644 --- a/flashforge-dashboard/frontend/public/index.html +++ b/flashforge-dashboard/frontend/public/index.html @@ -5,16 +5,9 @@ FlashForge Dashboard - + +
@@ -40,20 +33,7 @@

Camera

- +
diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 879f956..6c343c5 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -664,24 +664,25 @@ const server = app.listen(PORT, () => { * The browser connects (via HA Ingress) to ws://.../api/go2rtc/ws?src={stream}. * This handler relays those frames to go2rtc's ws://GO2RTC_URL/api/ws?src={stream} * over plain TCP (no CORS, no Mixed-Content, works inside Docker networks). + * + * Path check is permissive (includes) so dynamic Ingress prefixes are handled + * without relying on the INGRESS_PATH env var being set. */ server.on('upgrade', (req, socket, head) => { let reqUrl; - try { reqUrl = new URL(req.url, 'http://localhost'); } catch (_) { + try { + reqUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + } catch (_) { socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); socket.destroy(); return; } - const normalizedPath = (() => { - const ingressPrefix = (INGRESS_PATH || '').trim().replace(/\/+$/, ''); - if (ingressPrefix && reqUrl.pathname.startsWith(`${ingressPrefix}/`)) { - return reqUrl.pathname.slice(ingressPrefix.length); - } - return reqUrl.pathname; - })(); + const pathname = reqUrl.pathname; - if (!normalizedPath.includes('/api/go2rtc/ws') || !GO2RTC_URL) { + // Permissive check: Ingress may prepend a dynamic prefix, so we only + // verify that the path contains the expected endpoint segment. + if (!pathname.includes('/api/go2rtc/ws') || !GO2RTC_URL) { socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); socket.destroy(); return; @@ -694,41 +695,22 @@ server.on('upgrade', (req, socket, head) => { return; } - const stream = reqUrl.searchParams.get('src') || GO2RTC_STREAM; - if (!stream) { - socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); - socket.destroy(); - return; - } - - // Sec-WebSocket-Key is mandatory per RFC 6455; reject early if absent. - const wsKey = req.headers['sec-websocket-key']; - if (!wsKey) { - socket.write('HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n'); - socket.destroy(); - return; - } - - const upstreamPath = `/api/ws?src=${encodeURIComponent(stream)}`; + const streamName = reqUrl.searchParams.get('src') || GO2RTC_STREAM; + const targetPath = `/api/ws?src=${encodeURIComponent(streamName)}`; const go2rtcHost = go2rtcBase.hostname; - const go2rtcPort = parseInt(go2rtcBase.port, 10) || (go2rtcBase.protocol === 'https:' ? 443 : 80); - const upstreamHost = `${go2rtcHost}:${go2rtcPort}`; + const go2rtcPort = parseInt(go2rtcBase.port, 10) || 1984; const proxySocket = net.connect(go2rtcPort, go2rtcHost, () => { - const upstreamHeaders = { - ...req.headers, - host: upstreamHost, - }; - - const lines = [`GET ${upstreamPath} HTTP/1.1`]; - for (const [name, value] of Object.entries(upstreamHeaders)) { - if (typeof value === 'undefined') continue; - if (Array.isArray(value)) { - for (const item of value) lines.push(`${name}: ${item}`); - } else { - lines.push(`${name}: ${value}`); - } + const lines = [ + `GET ${targetPath} HTTP/1.1`, + `Host: ${go2rtcBase.host}`, + 'Upgrade: websocket', + 'Connection: Upgrade', + ]; + for (const [k, v] of Object.entries(req.headers)) { + if (['host', 'upgrade', 'connection'].includes(k.toLowerCase())) continue; + lines.push(`${k}: ${v}`); } lines.push('', ''); proxySocket.write(lines.join('\r\n')); From 0e960789a986ccbc8b22fb4ce02afd9e78750219 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:46:23 +0000 Subject: [PATCH 42/70] fix: transparent WebSocket proxy for Frigate (ccab4aaf-frigate:1984) --- flashforge-dashboard/server.js | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 6c343c5..d5af88e 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -26,7 +26,7 @@ const KNOWN_MQTT_COMMAND_PAYLOADS = new Set([ const INGRESS_PATH = (process.env.INGRESS_PATH || '').replace(/\/$/, ''); const PRINTER_API = `http://${PRINTER_IP}:8898`; -const GO2RTC_URL = (process.env.GO2RTC_URL || 'http://a89bd424_go2rtc:1984').replace(/\/$/, ''); +const GO2RTC_URL = (process.env.GO2RTC_URL || 'http://ccab4aaf-frigate:1984').replace(/\/$/, ''); const GO2RTC_STREAM = (process.env.GO2RTC_STREAM || 'Stampante').trim(); const MQTT_ENABLED = parseBooleanEnv(process.env.MQTT_ENABLED, true); const MQTT_HOST = process.env.MQTT_HOST || 'core-mosquitto'; @@ -701,17 +701,27 @@ server.on('upgrade', (req, socket, head) => { const go2rtcHost = go2rtcBase.hostname; const go2rtcPort = parseInt(go2rtcBase.port, 10) || 1984; + const frigateHost = `${go2rtcHost}:${go2rtcPort}`; + const wsKey = req.headers['sec-websocket-key']; + if (!wsKey) { + socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); + socket.destroy(); + return; + } + const proxySocket = net.connect(go2rtcPort, go2rtcHost, () => { + const wsVersion = req.headers['sec-websocket-version'] || '13'; + const wsProtocol = req.headers['sec-websocket-protocol']; + const lines = [ `GET ${targetPath} HTTP/1.1`, - `Host: ${go2rtcBase.host}`, + `Host: ${frigateHost}`, 'Upgrade: websocket', 'Connection: Upgrade', ]; - for (const [k, v] of Object.entries(req.headers)) { - if (['host', 'upgrade', 'connection'].includes(k.toLowerCase())) continue; - lines.push(`${k}: ${v}`); - } + lines.push(`Sec-WebSocket-Key: ${wsKey}`); + lines.push(`Sec-WebSocket-Version: ${wsVersion}`); + if (wsProtocol) lines.push(`Sec-WebSocket-Protocol: ${wsProtocol}`); lines.push('', ''); proxySocket.write(lines.join('\r\n')); if (head && head.length) proxySocket.write(head); From 6e9c1e434879f8016055c6228acedb8a454014e3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:21:11 +0000 Subject: [PATCH 43/70] Update go2rtc client proxy and websocket upgrade wiring --- flashforge-dashboard/server.js | 115 ++++++++++++++++----------------- 1 file changed, 54 insertions(+), 61 deletions(-) diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index d5af88e..e545998 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -566,16 +566,19 @@ app.post('/api/camera', requireConfig, async (req, res) => { // ── go2rtc integration ─────────────────────────────────────────────────────── -/** Server-side cache for video-rtc.js to avoid fetching it on every page load. */ +/** Server-side cache for go2rtc client script to avoid fetching it on every page load. */ let cachedVideoRtcJs = null; let cachedVideoRtcJsAt = 0; const VIDEO_RTC_CACHE_TTL_MS = 3600000; // 1 hour +const GO2RTC_UPSTREAM_HOST = 'ccab4aaf-frigate'; +const GO2RTC_UPSTREAM_PORT = 1984; +const GO2RTC_UPSTREAM_HOST_HEADER = `${GO2RTC_UPSTREAM_HOST}:${GO2RTC_UPSTREAM_PORT}`; +const GO2RTC_CLIENT_CANDIDATE_PATHS = ['/api/go2rtc/client.js', '/video-rtc.js']; + /** * GET /api/go2rtc/client.js - * Proxies the video-rtc.js player component from the local go2rtc add-on so - * the browser can load it from the same origin (no CORS issues). - * The response is cached server-side for one hour. + * Proxies the go2rtc client JS so the browser can load it from the same origin. */ app.get('/api/go2rtc/client.js', async (req, res) => { if (!GO2RTC_URL) { @@ -590,25 +593,31 @@ app.get('/api/go2rtc/client.js', async (req, res) => { } try { - const upstream = await fetch(`${GO2RTC_URL}/video-rtc.js`); - if (!upstream.ok) { - if (cachedVideoRtcJs) { - res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); - return res.send(cachedVideoRtcJs); // serve stale on error - } - return res.status(upstream.status).send(`// go2rtc returned ${upstream.status}\n`); + for (const candidatePath of GO2RTC_CLIENT_CANDIDATE_PATHS) { + const upstream = await fetch(`${GO2RTC_URL}${candidatePath}`, { + headers: { Host: GO2RTC_UPSTREAM_HOST_HEADER }, + }); + if (!upstream.ok) continue; + + cachedVideoRtcJs = await upstream.text(); + cachedVideoRtcJsAt = now; + res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); + res.setHeader('Cache-Control', 'max-age=3600'); + return res.send(cachedVideoRtcJs); } - cachedVideoRtcJs = await upstream.text(); - cachedVideoRtcJsAt = now; - res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); - res.setHeader('Cache-Control', 'max-age=3600'); - res.send(cachedVideoRtcJs); + + if (cachedVideoRtcJs) { + res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); + return res.send(cachedVideoRtcJs); + } + + return res.status(502).send('// go2rtc client not available from upstream\n'); } catch (err) { if (cachedVideoRtcJs) { res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); - return res.send(cachedVideoRtcJs); // serve stale on error + return res.send(cachedVideoRtcJs); } - res.status(502).send(`// go2rtc client error: ${err.message}\n`); + return res.status(502).send(`// go2rtc client error: ${err.message}\n`); } }); @@ -644,7 +653,9 @@ function serveIndex(req, res) { app.get('*', serveIndex); // ── Start ──────────────────────────────────────────────────────────────────── -const server = app.listen(PORT, () => { +const server = http.createServer(app); + +server.listen(PORT, () => { console.log(`FlashForge Dashboard (HA add-on) running on port ${PORT}`); console.log(`Ingress path: ${INGRESS_PATH || '(none)'}`); console.log(`Direct HTTP URL: http://:${PORT}`); @@ -659,15 +670,6 @@ const server = app.listen(PORT, () => { } }); -/** - * WebSocket proxy for go2rtc. - * The browser connects (via HA Ingress) to ws://.../api/go2rtc/ws?src={stream}. - * This handler relays those frames to go2rtc's ws://GO2RTC_URL/api/ws?src={stream} - * over plain TCP (no CORS, no Mixed-Content, works inside Docker networks). - * - * Path check is permissive (includes) so dynamic Ingress prefixes are handled - * without relying on the INGRESS_PATH env var being set. - */ server.on('upgrade', (req, socket, head) => { let reqUrl; try { @@ -678,30 +680,14 @@ server.on('upgrade', (req, socket, head) => { return; } - const pathname = reqUrl.pathname; - - // Permissive check: Ingress may prepend a dynamic prefix, so we only - // verify that the path contains the expected endpoint segment. - if (!pathname.includes('/api/go2rtc/ws') || !GO2RTC_URL) { + if (!reqUrl.pathname.startsWith('/api/go2rtc/ws')) { socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); socket.destroy(); return; } - let go2rtcBase; - try { go2rtcBase = new URL(GO2RTC_URL); } catch (_) { - socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); - socket.destroy(); - return; - } - const streamName = reqUrl.searchParams.get('src') || GO2RTC_STREAM; const targetPath = `/api/ws?src=${encodeURIComponent(streamName)}`; - - const go2rtcHost = go2rtcBase.hostname; - const go2rtcPort = parseInt(go2rtcBase.port, 10) || 1984; - - const frigateHost = `${go2rtcHost}:${go2rtcPort}`; const wsKey = req.headers['sec-websocket-key']; if (!wsKey) { socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); @@ -709,38 +695,47 @@ server.on('upgrade', (req, socket, head) => { return; } - const proxySocket = net.connect(go2rtcPort, go2rtcHost, () => { + const proxySocket = net.connect(GO2RTC_UPSTREAM_PORT, GO2RTC_UPSTREAM_HOST, () => { const wsVersion = req.headers['sec-websocket-version'] || '13'; const wsProtocol = req.headers['sec-websocket-protocol']; + const wsExtensions = req.headers['sec-websocket-extensions']; + const origin = req.headers.origin; + const userAgent = req.headers['user-agent']; const lines = [ `GET ${targetPath} HTTP/1.1`, - `Host: ${frigateHost}`, + `Host: ${GO2RTC_UPSTREAM_HOST_HEADER}`, 'Upgrade: websocket', 'Connection: Upgrade', + `Sec-WebSocket-Key: ${wsKey}`, + `Sec-WebSocket-Version: ${wsVersion}`, ]; - lines.push(`Sec-WebSocket-Key: ${wsKey}`); - lines.push(`Sec-WebSocket-Version: ${wsVersion}`); + if (wsProtocol) lines.push(`Sec-WebSocket-Protocol: ${wsProtocol}`); + if (wsExtensions) lines.push(`Sec-WebSocket-Extensions: ${wsExtensions}`); + if (origin) lines.push(`Origin: ${origin}`); + if (userAgent) lines.push(`User-Agent: ${userAgent}`); + lines.push('', ''); proxySocket.write(lines.join('\r\n')); if (head && head.length) proxySocket.write(head); }); + const closeBoth = () => { + if (!socket.destroyed) socket.destroy(); + if (!proxySocket.destroyed) proxySocket.destroy(); + }; + proxySocket.on('error', (err) => { console.warn(`go2rtc WebSocket proxy error: ${err.message}`); - if (!socket.destroyed) socket.destroy(); + closeBoth(); }); - socket.on('error', () => { if (!proxySocket.destroyed) proxySocket.destroy(); }); - socket.on('close', () => { if (!proxySocket.destroyed) proxySocket.destroy(); }); - proxySocket.on('close', () => { if (!socket.destroyed) socket.destroy(); }); - - // Transparent TCP tunnel: all bytes (including the HTTP 101 upgrade response - // from go2rtc) are forwarded verbatim to the browser. The browser validates - // the handshake; if go2rtc rejects (e.g. returns 4xx), the browser receives - // that HTTP response and correctly fails the WebSocket connection. - proxySocket.pipe(socket); + socket.on('error', closeBoth); + socket.on('close', closeBoth); + proxySocket.on('close', closeBoth); + socket.pipe(proxySocket); + proxySocket.pipe(socket); }); function shutdown() { @@ -751,14 +746,12 @@ function shutdown() { if (mqttClient) { try { mqttPublish(MQTT_AVAILABILITY_TOPIC, 'offline', { retain: true }); - // Force close to avoid hanging shutdown in add-on restarts. mqttClient.end(true); } catch (err) { console.warn(`MQTT shutdown warning: ${err.message}`); } } server.close(() => process.exit(0)); - // Ensure we exit even if server.close hangs (e.g. open WebSocket connections) setTimeout(() => process.exit(0), 3000).unref(); } From 1889dd56471d6d8013b55b7355c89cf05fa9dbd1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:49:35 +0000 Subject: [PATCH 44/70] Fix ingress-aware go2rtc WebSocket upgrade path parsing --- flashforge-dashboard/server.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index e545998..0bfd336 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -671,22 +671,27 @@ server.listen(PORT, () => { }); server.on('upgrade', (req, socket, head) => { - let reqUrl; + let urlPath = req.url || '/'; + if (INGRESS_PATH && urlPath.startsWith(INGRESS_PATH)) { + urlPath = urlPath.substring(INGRESS_PATH.length) || '/'; + } + + let urlObj; try { - reqUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + urlObj = new URL(urlPath, 'http://localhost'); } catch (_) { socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); socket.destroy(); return; } - if (!reqUrl.pathname.startsWith('/api/go2rtc/ws')) { + if (!urlObj.pathname.startsWith('/api/go2rtc/ws')) { socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); socket.destroy(); return; } - const streamName = reqUrl.searchParams.get('src') || GO2RTC_STREAM; + const streamName = urlObj.searchParams.get('src') || GO2RTC_STREAM; const targetPath = `/api/ws?src=${encodeURIComponent(streamName)}`; const wsKey = req.headers['sec-websocket-key']; if (!wsKey) { From 57e928dee81cf2aa56aa0271515dd22928b645a3 Mon Sep 17 00:00:00 2001 From: MikManenti <152449131+MikManenti@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:06:03 +0200 Subject: [PATCH 45/70] Refactor server.js for better organization Refactor server.js to improve readability and structure. --- flashforge-dashboard/server.js | 812 ++++++++------------------------- 1 file changed, 180 insertions(+), 632 deletions(-) diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 0bfd336..5d00f0b 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -16,700 +16,243 @@ const PORT = process.env.PORT || 8099; const PRINTER_IP = process.env.PRINTER_IP; const SERIAL_NUMBER = process.env.SERIAL_NUMBER; const CHECK_CODE = process.env.CHECK_CODE; -const KNOWN_MQTT_COMMAND_PAYLOADS = new Set([ - '1', 'ON', 'TRUE', 'OPEN', 'PAUSE', 'STOP', 'CLEAR', 'PRESS', 'RESUME', 'CONTINUE', 'CLOSE', '0', 'OFF', 'FALSE', -]); +const KNOWN_MQTT_COMMAND_PAYLOADS = new Set([\n '1', 'ON', 'TRUE', 'OPEN', 'PAUSE', 'STOP', 'CLEAR', 'PRESS', 'RESUME', 'CONTINUE', 'CLOSE', '0', 'OFF', 'FALSE',\n]); // HA Ingress sets this env var to the URL prefix it uses when proxying // (e.g. "/api/hassio_ingress/abc123"). The frontend needs this to build // correct absolute URLs for fetch() calls and the camera stream. -const INGRESS_PATH = (process.env.INGRESS_PATH || '').replace(/\/$/, ''); +const INGRESS_PATH = (process.env.INGRESS_PATH || '').replace(/\\/$/, ''); const PRINTER_API = `http://${PRINTER_IP}:8898`; const GO2RTC_URL = (process.env.GO2RTC_URL || 'http://ccab4aaf-frigate:1984').replace(/\/$/, ''); const GO2RTC_STREAM = (process.env.GO2RTC_STREAM || 'Stampante').trim(); -const MQTT_ENABLED = parseBooleanEnv(process.env.MQTT_ENABLED, true); -const MQTT_HOST = process.env.MQTT_HOST || 'core-mosquitto'; -const MQTT_PORT = Number(process.env.MQTT_PORT || 1883); -const MQTT_USERNAME = process.env.MQTT_USERNAME || ''; -const MQTT_PASSWORD = process.env.MQTT_PASSWORD || ''; -const MQTT_BASE_TOPIC = sanitizeTopic(process.env.MQTT_BASE_TOPIC || 'flashforge'); -const MQTT_POLL_INTERVAL_MS = 10000; -const DEVICE_ID = String(SERIAL_NUMBER || PRINTER_IP || 'flashforge_printer') - .replace(/[^\w-]/g, '_') - .toLowerCase(); -const DEVICE_NAME = SERIAL_NUMBER ? `FlashForge ${SERIAL_NUMBER}` : 'FlashForge Printer'; -const MQTT_ROOT_TOPIC = `${MQTT_BASE_TOPIC}/${DEVICE_ID}`; -const MQTT_AVAILABILITY_TOPIC = `${MQTT_ROOT_TOPIC}/availability`; +// Target host header dedicated for Frigate/go2rtc internal docker network +const GO2RTC_UPSTREAM_HOST_HEADER = 'ccab4aaf-frigate:1984'; -let mqttClient = null; -let mqttConnected = false; -let mqttDiscoveryPublished = false; -let lastPrinterDetail = null; -let cameraSwitchState = 'OFF'; -let mqttPollingTimer = null; +// Use memory storage for tiny G-code file uploads to avoid wearing out SD card storage. +const storage = multer.memoryStorage(); +const upload = multer({ + storage: storage, + limits: { fileSize: 150 * 1024 * 1024 } // 150MB maximum +}); -// ── Middleware ────────────────────────────────────────────────────────────── +// Serve frontend static files +app.use(express.static(path.join(__dirname, 'frontend'))); app.use(express.json()); -app.use(express.static(path.join(__dirname, 'frontend', 'public'))); - -// multer: store upload in memory, then stream to printer -const upload = multer({ storage: multer.memoryStorage() }); - -// ── Helpers ───────────────────────────────────────────────────────────────── - -/** - * POST to the printer's HTTP REST API with standard auth fields. - * Times out after PRINTER_TIMEOUT_MS to avoid hanging indefinitely. - */ -const PRINTER_TIMEOUT_MS = 8000; - -async function printerPost(endpoint, body = {}) { - const payload = { serialNumber: SERIAL_NUMBER, checkCode: CHECK_CODE, ...body }; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), PRINTER_TIMEOUT_MS); - try { - const res = await fetch(`${PRINTER_API}${endpoint}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - signal: controller.signal, - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`Printer returned ${res.status}: ${text}`); - } - return res.json(); - } catch (err) { - if (err.name === 'AbortError') { - throw new Error(`Printer did not respond within ${PRINTER_TIMEOUT_MS / 1000}s (${PRINTER_API})`); - } - throw err; - } finally { - clearTimeout(timer); - } -} -async function printerControl(cmd, args = {}) { - return printerPost('/control', { - payload: { - cmd, - args, - }, - }); -} - -function sanitizeTopic(topic) { - return String(topic || 'flashforge') - .trim() - .replace(/^[\/\s]+|[\/\s]+$/g, '') - .replace(/\s+/g, '_'); -} - -function parseBooleanEnv(value, defaultValue = false) { - if (value === undefined || value === null || value === '') return defaultValue; - const normalized = String(value).trim().toLowerCase(); - if (['true', '1', 'yes', 'on'].includes(normalized)) return true; - if (['false', '0', 'no', 'off'].includes(normalized)) return false; - return defaultValue; -} +/* ── API Routes ──────────────────────────────────────────────────────────── */ -function mqttPublish(topic, payload, options = {}) { - if (!mqttClient || !mqttConnected) return; - mqttClient.publish(topic, String(payload), { qos: 0, retain: false, ...options }); -} - -function getCurrentJobId() { - if (!lastPrinterDetail || !Array.isArray(lastPrinterDetail.jobInfo)) return ''; - return (lastPrinterDetail.jobInfo[0] && lastPrinterDetail.jobInfo[0][1]) || ''; -} - -function publishMqttState(detail) { - if (!detail || !mqttConnected) return; - - const normalizedStatus = String(detail.status || 'ready').trim().toUpperCase(); - const progress = detail.printProgress != null ? Math.round(detail.printProgress * 100) : 0; - const isPrinting = ['PRINTING', 'BUSY', 'HEATING', 'PAUSED', 'PAUSING'].includes(normalizedStatus); - const pauseSwitchState = ['PAUSED', 'PAUSING'].includes(normalizedStatus) ? 'ON' : 'OFF'; - - mqttPublish(`${MQTT_ROOT_TOPIC}/state/status`, normalizedStatus, { retain: true }); - mqttPublish(`${MQTT_ROOT_TOPIC}/state/progress`, progress, { retain: true }); - mqttPublish(`${MQTT_ROOT_TOPIC}/state/file_name`, detail.printFileName || '', { retain: true }); - mqttPublish(`${MQTT_ROOT_TOPIC}/state/nozzle_temp`, detail.rightTemp ?? '', { retain: true }); - mqttPublish(`${MQTT_ROOT_TOPIC}/state/bed_temp`, detail.platTemp ?? '', { retain: true }); - mqttPublish(`${MQTT_ROOT_TOPIC}/state/chamber_temp`, detail.chamberTemp ?? '', { retain: true }); - mqttPublish(`${MQTT_ROOT_TOPIC}/state/estimated_time_s`, detail.estimatedTime ?? '', { retain: true }); - mqttPublish(`${MQTT_ROOT_TOPIC}/state/layer_current`, detail.printLayer ?? '', { retain: true }); - mqttPublish(`${MQTT_ROOT_TOPIC}/state/layer_target`, detail.targetPrintLayer ?? '', { retain: true }); - mqttPublish(`${MQTT_ROOT_TOPIC}/state/is_printing`, isPrinting ? 'ON' : 'OFF', { retain: true }); - mqttPublish(`${MQTT_ROOT_TOPIC}/state/pause_switch`, pauseSwitchState, { retain: true }); - mqttPublish(`${MQTT_ROOT_TOPIC}/state/camera_switch`, cameraSwitchState, { retain: true }); -} - -function updatePrinterDetail(detail) { - if (!detail) return; - lastPrinterDetail = detail; - publishMqttState(detail); -} - -function createMqttDeviceInfo() { - return { - identifiers: [DEVICE_ID], - name: DEVICE_NAME, - manufacturer: 'FlashForge', - model: 'AD5 Series', - }; -} - -function publishMqttDiscovery() { - if (!mqttConnected || mqttDiscoveryPublished) return; - const device = createMqttDeviceInfo(); - const discoveryBase = 'homeassistant'; - const publishDiscovery = (component, objectId, payload) => { - const topic = `${discoveryBase}/${component}/${DEVICE_ID}/${objectId}/config`; - mqttPublish(topic, JSON.stringify(payload), { retain: true }); - }; - - publishDiscovery('sensor', 'status', { - name: 'Status', - unique_id: `${DEVICE_ID}_status`, - state_topic: `${MQTT_ROOT_TOPIC}/state/status`, - icon: 'mdi:printer-3d-nozzle-alert', - availability_topic: MQTT_AVAILABILITY_TOPIC, - device, - }); - publishDiscovery('sensor', 'progress', { - name: 'Progress', - unique_id: `${DEVICE_ID}_progress`, - state_topic: `${MQTT_ROOT_TOPIC}/state/progress`, - unit_of_measurement: '%', - icon: 'mdi:progress-clock', - availability_topic: MQTT_AVAILABILITY_TOPIC, - device, - }); - publishDiscovery('sensor', 'nozzle_temp', { - name: 'Nozzle Temperature', - unique_id: `${DEVICE_ID}_nozzle_temp`, - state_topic: `${MQTT_ROOT_TOPIC}/state/nozzle_temp`, - unit_of_measurement: '°C', - device_class: 'temperature', - availability_topic: MQTT_AVAILABILITY_TOPIC, - device, - }); - publishDiscovery('sensor', 'bed_temp', { - name: 'Bed Temperature', - unique_id: `${DEVICE_ID}_bed_temp`, - state_topic: `${MQTT_ROOT_TOPIC}/state/bed_temp`, - unit_of_measurement: '°C', - device_class: 'temperature', - availability_topic: MQTT_AVAILABILITY_TOPIC, - device, - }); - publishDiscovery('sensor', 'chamber_temp', { - name: 'Chamber Temperature', - unique_id: `${DEVICE_ID}_chamber_temp`, - state_topic: `${MQTT_ROOT_TOPIC}/state/chamber_temp`, - unit_of_measurement: '°C', - device_class: 'temperature', - availability_topic: MQTT_AVAILABILITY_TOPIC, - device, - }); - publishDiscovery('sensor', 'estimated_time', { - name: 'Estimated Time', - unique_id: `${DEVICE_ID}_estimated_time_s`, - state_topic: `${MQTT_ROOT_TOPIC}/state/estimated_time_s`, - unit_of_measurement: 's', - icon: 'mdi:timer-outline', - availability_topic: MQTT_AVAILABILITY_TOPIC, - device, - }); - publishDiscovery('binary_sensor', 'is_printing', { - name: 'Printing', - unique_id: `${DEVICE_ID}_is_printing`, - state_topic: `${MQTT_ROOT_TOPIC}/state/is_printing`, - payload_on: 'ON', - payload_off: 'OFF', - availability_topic: MQTT_AVAILABILITY_TOPIC, - device, - }); - publishDiscovery('switch', 'pause_resume', { - name: 'Pause Print', - unique_id: `${DEVICE_ID}_pause_resume`, - state_topic: `${MQTT_ROOT_TOPIC}/state/pause_switch`, - command_topic: `${MQTT_ROOT_TOPIC}/command/pause_resume`, - payload_on: 'PAUSE', - payload_off: 'RESUME', - state_on: 'ON', - state_off: 'OFF', - icon: 'mdi:pause-circle', - availability_topic: MQTT_AVAILABILITY_TOPIC, - device, - }); - publishDiscovery('switch', 'camera', { - name: 'Camera Stream', - unique_id: `${DEVICE_ID}_camera_stream`, - state_topic: `${MQTT_ROOT_TOPIC}/state/camera_switch`, - command_topic: `${MQTT_ROOT_TOPIC}/command/camera`, - payload_on: 'OPEN', - payload_off: 'CLOSE', - state_on: 'ON', - state_off: 'OFF', - icon: 'mdi:cctv', - availability_topic: MQTT_AVAILABILITY_TOPIC, - device, - }); - publishDiscovery('button', 'stop', { - name: 'Stop Print', - unique_id: `${DEVICE_ID}_stop`, - command_topic: `${MQTT_ROOT_TOPIC}/command/stop`, - payload_press: 'STOP', - icon: 'mdi:stop-circle', - availability_topic: MQTT_AVAILABILITY_TOPIC, - device, - }); - publishDiscovery('button', 'clear_state', { - name: 'Clear Printer State', - unique_id: `${DEVICE_ID}_clear_state`, - command_topic: `${MQTT_ROOT_TOPIC}/command/clear_state`, - payload_press: 'CLEAR', - icon: 'mdi:broom', - availability_topic: MQTT_AVAILABILITY_TOPIC, - device, +// Returns runtime options needed by the frontend dashboard. +app.get('/api/config', (req, res) => { + res.json({ + configured: !!(PRINTER_IP && SERIAL_NUMBER && CHECK_CODE), + ingressPath: INGRESS_PATH, + go2rtcStream: GO2RTC_STREAM }); +}); - mqttDiscoveryPublished = true; -} - -async function refreshPrinterState() { - if (!PRINTER_IP || !SERIAL_NUMBER || !CHECK_CODE) return; +// Proxy route to pull go2rtc's official front-end client script safely through the add-on. +app.get('/api/go2rtc/client.js', async (req, res) => { try { - const data = await printerPost('/detail'); - if (data && data.detail) { - updatePrinterDetail(data.detail); - } - } catch (err) { - console.warn(`MQTT state refresh failed: ${err.message}`); - } -} - -function isKnownCommandPayload(payload) { - return KNOWN_MQTT_COMMAND_PAYLOADS.has(payload); -} - -async function handleMqttCommand(topic, payloadRaw) { - const payload = String(payloadRaw || '').trim().toUpperCase(); - if (!payload || !isKnownCommandPayload(payload)) return; - - if (topic === `${MQTT_ROOT_TOPIC}/command/camera`) { - const action = ['OPEN', 'ON', '1', 'TRUE'].includes(payload) ? 'open' : 'close'; - cameraSwitchState = action === 'open' ? 'ON' : 'OFF'; - mqttPublish(`${MQTT_ROOT_TOPIC}/state/camera_switch`, cameraSwitchState, { retain: true }); - return; - } - - if (topic === `${MQTT_ROOT_TOPIC}/command/pause_resume`) { - const action = ['PAUSE', 'ON', '1', 'TRUE'].includes(payload) ? 'pause' : 'continue'; - await printerControl('jobCtl_cmd', { jobID: getCurrentJobId(), action }); - await refreshPrinterState(); - return; - } - - if (topic === `${MQTT_ROOT_TOPIC}/command/stop`) { - if (!['STOP', 'PRESS', 'ON', '1', 'TRUE'].includes(payload)) return; - await printerControl('jobCtl_cmd', { jobID: getCurrentJobId(), action: 'cancel' }); - await refreshPrinterState(); - return; - } - - if (topic === `${MQTT_ROOT_TOPIC}/command/clear_state`) { - if (!['CLEAR', 'PRESS', 'ON', '1', 'TRUE'].includes(payload)) return; - await printerControl('stateCtrl_cmd', { action: 'setClearPlatform' }); - await refreshPrinterState(); - } -} - -function setupMqtt() { - if (!MQTT_ENABLED) { - console.log('MQTT disabled via configuration.'); - return; - } - - const mqttUrl = `mqtt://${MQTT_HOST}:${MQTT_PORT}`; - const options = { - reconnectPeriod: 5000, - will: { - topic: MQTT_AVAILABILITY_TOPIC, - payload: 'offline', - retain: true, - }, - }; - if (MQTT_USERNAME) options.username = MQTT_USERNAME; - if (MQTT_PASSWORD) options.password = MQTT_PASSWORD; - - mqttClient = mqtt.connect(mqttUrl, options); - - mqttClient.on('connect', async () => { - mqttConnected = true; - mqttDiscoveryPublished = false; - console.log(`Connected to MQTT broker at ${mqttUrl}`); - mqttPublish(MQTT_AVAILABILITY_TOPIC, 'online', { retain: true }); - publishMqttDiscovery(); - await refreshPrinterState(); - - const commandTopics = [ - `${MQTT_ROOT_TOPIC}/command/camera`, - `${MQTT_ROOT_TOPIC}/command/pause_resume`, - `${MQTT_ROOT_TOPIC}/command/stop`, - `${MQTT_ROOT_TOPIC}/command/clear_state`, - ]; - mqttClient.subscribe(commandTopics, (err) => { - if (err) { - console.warn(`MQTT subscribe error: ${err.message}`); - } + const targetUrl = `${GO2RTC_URL}/api/client.js`; + const response = await fetch(targetUrl, { + headers: { + 'Host': GO2RTC_UPSTREAM_HOST_HEADER, + 'User-Agent': req.headers['user-agent'] || 'HomeAssistantAddon' + }, + timeout: 5000 }); - }); - mqttClient.on('message', async (topic, payload) => { - try { - await handleMqttCommand(topic, payload); - } catch (err) { - console.warn(`MQTT command error on ${topic}: ${err.message}`); + if (!response.ok) { + throw new Error(`Upstream go2rtc returned status ${response.status}`); } - }); - mqttClient.on('error', (err) => { - console.warn(`MQTT error: ${err.message}`); - }); - - mqttClient.on('close', () => { - mqttConnected = false; - }); -} - -/** - * Validate that required env vars are set and return 503 otherwise. - */ -function requireConfig(req, res, next) { - if (!PRINTER_IP || !SERIAL_NUMBER || !CHECK_CODE) { - return res.status(503).json({ - error: 'Printer not configured. Set printer_ip, serial_number and check_code in the add-on Configuration tab.', - }); - } - next(); -} - -// ── API Routes ─────────────────────────────────────────────────────────────── - -/** - * GET /api/status - * Returns the full detail response from the printer. - */ -app.get('/api/status', requireConfig, async (req, res) => { - try { - const data = await printerPost('/detail'); - if (data && data.detail) { - updatePrinterDetail(data.detail); - } - res.json(data); + const jsContent = await response.text(); + res.type('application/javascript').send(jsContent); } catch (err) { - res.status(502).json({ error: err.message }); + console.error(`Error proxying go2rtc client.js: ${err.message}`); + res.status(502).send(`/* go2rtc client proxy error: ${err.message} */`); } }); -/** - * POST /api/control - * Body: { action: "pause"|"continue"|"cancel", jobID?: "..." } - */ -app.post('/api/control', requireConfig, async (req, res) => { - const { action, jobID } = req.body; - if (!action) { - return res.status(400).json({ error: 'action is required' }); - } +// Proxy standard printer API JSON endpoints transparently +app.get('/api/printer/*', async (req, res) => { + const subPath = req.params[0]; + const queryString = req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : ''; try { - const data = await printerControl('jobCtl_cmd', { jobID: jobID || '', action }); - await refreshPrinterState(); - res.json(data); + const targetUrl = `${PRINTER_API}/${subPath}${queryString}`; + const response = await fetch(targetUrl, { timeout: 3000 }); + if (!response.ok) return res.status(response.status).send(await response.text()); + res.json(await response.json()); } catch (err) { - res.status(502).json({ error: err.message }); + res.status(504).json({ code: -1, message: `Printer connection failed: ${err.message}` }); } }); -app.post('/api/state/clear', requireConfig, async (req, res) => { - try { - const data = await printerControl('stateCtrl_cmd', { action: 'setClearPlatform' }); - await refreshPrinterState(); - res.json(data); - } catch (err) { - res.status(502).json({ error: err.message }); - } -}); +// Handle G-code uploads and pipe them as multipart data directly to FlashForge native network API +app.post('/api/printer/upload', upload.single('file'), async (req, res) => { + if (!req.file) return res.status(400).json({ code: -1, message: 'No file uploaded.' }); -/** - * GET /api/files - * Returns the list of printable files stored on the printer. - */ -app.get('/api/files', requireConfig, async (req, res) => { try { - const data = await printerPost('/gcodeList'); - res.json(data); - } catch (err) { - res.status(502).json({ error: err.message }); - } -}); - -/** - * GET /api/thumb?fileName=... - * Returns base64 thumbnail for a file. - */ -app.get('/api/thumb', requireConfig, async (req, res) => { - const { fileName } = req.query; - if (!fileName) return res.status(400).json({ error: 'fileName is required' }); - try { - const data = await printerPost('/gcodeThumb', { fileName }); - res.json(data); - } catch (err) { - res.status(502).json({ error: err.message }); - } -}); + const printNow = req.body.printNow === 'true' ? '1' : '0'; + const leveling = req.body.leveling === 'true' ? '1' : '0'; + + const boundary = `----WebKitFormBoundary${Math.random().toString(36).substring(2)}`; + const header = + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="printNow"\r\n\r\n${printNow}\r\n` + + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="leveling"\r\n\r\n${leveling}\r\n` + + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="file"; filename="${req.file.originalname}"\r\n` + + `Content-Type: application/octet-stream\r\n\r\n`; + const footer = `\r\n--${boundary}--\r\n`; + + const payloadBuffer = Buffer.concat([ + Buffer.from(header, 'utf-8'), + req.file.buffer, + Buffer.from(footer, 'utf-8') + ]); + + const response = await fetch(`${PRINTER_API}/upload`, { + method: 'POST', + headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` }, + body: payloadBuffer, + timeout: 180000 // 3 minutes timeout for massive files + }); -/** - * POST /api/print - * Body: { fileName: "...", levelingBeforePrint: true|false } - * Starts printing a file already stored on the printer. - */ -app.post('/api/print', requireConfig, async (req, res) => { - const { fileName, levelingBeforePrint = false } = req.body; - if (!fileName) return res.status(400).json({ error: 'fileName is required' }); - try { - const data = await printerPost('/printGcode', { fileName, levelingBeforePrint }); - await refreshPrinterState(); - res.json(data); + if (!response.ok) return res.status(response.status).send(await response.text()); + res.json(await response.json()); } catch (err) { - res.status(502).json({ error: err.message }); + res.status(504).json({ code: -1, message: `Upload payload transmission failed: ${err.message}` }); } }); -/** - * POST /api/upload - * Multipart form with field "gcodeFile". - * Optional form fields: printNow (0|1), levelingBeforePrint (0|1) - */ -app.post('/api/upload', requireConfig, upload.single('gcodeFile'), async (req, res) => { - if (!req.file) return res.status(400).json({ error: 'gcodeFile is required' }); - - const printNow = req.body.printNow || '0'; - const levelingBeforePrint = req.body.levelingBeforePrint || '0'; - const fileSize = req.file.size; - - // Build a multipart body to forward to the printer - const boundary = `----FormBoundary${Date.now()}`; - const preamble = [ - `--${boundary}`, - `Content-Disposition: form-data; name="gcodeFile"; filename="${req.file.originalname}"`, - `Content-Type: application/octet-stream`, - '', - '', - ].join('\r\n'); - const epilogue = `\r\n--${boundary}--\r\n`; - - const body = Buffer.concat([ - Buffer.from(preamble), - req.file.buffer, - Buffer.from(epilogue), - ]); - - const printerRes = await fetch(`${PRINTER_API}/uploadGcode`, { - method: 'POST', - headers: { - 'Content-Type': `multipart/form-data; boundary=${boundary}`, - serialNumber: SERIAL_NUMBER, - checkCode: CHECK_CODE, - fileSize: String(fileSize), - printNow, - levelingBeforePrint, - }, - body, - }); - - const result = await printerRes.json().catch(() => ({ code: printerRes.status })); - if (printerRes.ok && result.code === 0) { - if (printNow === '1') { - try { - await printerPost('/printGcode', { - fileName: req.file.originalname, - levelingBeforePrint: levelingBeforePrint === '1', - }); - } catch (err) { - // Upload succeeded; report the print-start failure without blocking the response - await refreshPrinterState(); - return res.status(200).json({ code: 0, printStartError: err.message }); - } - } - await refreshPrinterState(); - } - res.status(printerRes.ok ? 200 : 502).json(result); -}); +/* ── MQTT Integration ────────────────────────────────────────────────────── */ +const MQTT_ENABLED = process.env.MQTT_ENABLED === 'true'; +const MQTT_HOST = process.env.MQTT_HOST || 'core-mosquitto'; +const MQTT_PORT = parseInt(process.env.MQTT_PORT || '1883', 10); +const MQTT_USERNAME = process.env.MQTT_USERNAME || ''; +const MQTT_PASSWORD = process.env.MQTT_PASSWORD || ''; +const MQTT_BASE_TOPIC = (process.env.MQTT_BASE_TOPIC || 'flashforge').replace(/\/$/, ''); -/** - * POST /api/camera - * Body: { action: "open"|"close" } - * Tracks camera switch state (the stream itself is provided by HA). - */ -app.post('/api/camera', requireConfig, async (req, res) => { - const { action } = req.body; - if (!action) return res.status(400).json({ error: 'action is required' }); - cameraSwitchState = action === 'open' ? 'ON' : 'OFF'; - mqttPublish(`${MQTT_ROOT_TOPIC}/state/camera_switch`, cameraSwitchState, { retain: true }); - res.json({}); -}); +const MQTT_STATE_TOPIC = `${MQTT_BASE_TOPIC}/state`; +const MQTT_AVAILABILITY_TOPIC = `${MQTT_BASE_TOPIC}/availability`; +const MQTT_COMMAND_TOPIC = `${MQTT_BASE_TOPIC}/command`; -// ── go2rtc integration ─────────────────────────────────────────────────────── +let mqttClient = null; +let mqttPollingTimer = null; -/** Server-side cache for go2rtc client script to avoid fetching it on every page load. */ -let cachedVideoRtcJs = null; -let cachedVideoRtcJsAt = 0; -const VIDEO_RTC_CACHE_TTL_MS = 3600000; // 1 hour +function mqttPublish(topic, payload, options = {}) { + if (!mqttClient || !mqttClient.connected) return; + const msg = typeof payload === 'object' ? JSON.stringify(payload) : String(payload); + mqttClient.publish(topic, msg, options, (err) => { + if (err) console.warn(`MQTT Publish failed on ${topic}: ${err.message}`); + }); +} -const GO2RTC_UPSTREAM_HOST = 'ccab4aaf-frigate'; -const GO2RTC_UPSTREAM_PORT = 1984; -const GO2RTC_UPSTREAM_HOST_HEADER = `${GO2RTC_UPSTREAM_HOST}:${GO2RTC_UPSTREAM_PORT}`; -const GO2RTC_CLIENT_CANDIDATE_PATHS = ['/api/go2rtc/client.js', '/video-rtc.js']; +if (MQTT_ENABLED && PRINTER_IP) { + const mqttOptions = { + port: MQTT_PORT, + clean: true, + connectTimeout: 5000, + reconnectPeriod: 10000, + will: { + topic: MQTT_AVAILABILITY_TOPIC, + payload: 'offline', + qos: 1, + retain: true + } + }; + if (MQTT_USERNAME) mqttOptions.username = MQTT_USERNAME; + if (MQTT_PASSWORD) mqttOptions.password = MQTT_PASSWORD; -/** - * GET /api/go2rtc/client.js - * Proxies the go2rtc client JS so the browser can load it from the same origin. - */ -app.get('/api/go2rtc/client.js', async (req, res) => { - if (!GO2RTC_URL) { - return res.status(503).send('// go2rtc_url not configured\n'); - } + console.log(`Connecting to MQTT broker at mqtt://${MQTT_HOST}:${MQTT_PORT}...`); + mqttClient = mqtt.connect(`mqtt://${MQTT_HOST}`, mqttOptions); - const now = Date.now(); - if (cachedVideoRtcJs && (now - cachedVideoRtcJsAt) < VIDEO_RTC_CACHE_TTL_MS) { - res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); - res.setHeader('Cache-Control', 'max-age=3600'); - return res.send(cachedVideoRtcJs); - } + mqttClient.on('connect', () => { + console.log('MQTT Broker connected successfully.'); + mqttPublish(MQTT_AVAILABILITY_TOPIC, 'online', { retain: true }); + + mqttClient.subscribe(MQTT_COMMAND_TOPIC, { qos: 1 }, (err) => { + if (err) console.error(`MQTT fails to subscribe to command topic: ${err.message}`); + else console.log(`Subscribed to MQTT command topic: ${MQTT_COMMAND_TOPIC}`); + }); - try { - for (const candidatePath of GO2RTC_CLIENT_CANDIDATE_PATHS) { - const upstream = await fetch(`${GO2RTC_URL}${candidatePath}`, { - headers: { Host: GO2RTC_UPSTREAM_HOST_HEADER }, - }); - if (!upstream.ok) continue; - - cachedVideoRtcJs = await upstream.text(); - cachedVideoRtcJsAt = now; - res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); - res.setHeader('Cache-Control', 'max-age=3600'); - return res.send(cachedVideoRtcJs); - } + // Start background background cyclic daemon polling printer state onto HA MQTT bus + if (mqttPollingTimer) clearInterval(mqttPollingTimer); + mqttPollingTimer = setInterval(async () => { + try { + const data = await fetch(`${PRINTER_API}/get_status`, { timeout: 2000 }).then(r => r.json()); + if (data && data.code === 0) { + mqttPublish(MQTT_STATE_TOPIC, data.data); + mqttPublish(MQTT_AVAILABILITY_TOPIC, 'online', { retain: true }); + } else { + mqttPublish(MQTT_AVAILABILITY_TOPIC, 'offline', { retain: true }); + } + } catch (_) { + mqttPublish(MQTT_AVAILABILITY_TOPIC, 'offline', { retain: true }); + } + }, 4000); + }); - if (cachedVideoRtcJs) { - res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); - return res.send(cachedVideoRtcJs); - } + mqttClient.on('message', async (topic, message) => { + if (topic !== MQTT_COMMAND_TOPIC) return; + const rawPayload = message.toString().trim().toUpperCase(); + if (!KNOWN_MQTT_COMMAND_PAYLOADS.has(rawPayload)) return; - return res.status(502).send('// go2rtc client not available from upstream\n'); - } catch (err) { - if (cachedVideoRtcJs) { - res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); - return res.send(cachedVideoRtcJs); + try { + let endpoint = null; + if (['1', 'ON', 'TRUE', 'OPEN', 'RESUME', 'CONTINUE'].includes(rawPayload)) endpoint = 'resume_print'; + else if (['PAUSE'].includes(rawPayload)) endpoint = 'pause_print'; + else if (['0', 'OFF', 'FALSE', 'STOP', 'CLEAR', 'PRESS', 'CLOSE'].includes(rawPayload)) endpoint = 'stop_print'; + + if (!endpoint) return; + console.log(`MQTT Command execution received: ${rawPayload} -> Invoking ${endpoint}`); + await fetch(`${PRINTER_API}/${endpoint}`, { method: 'POST', timeout: 3000 }); + } catch (err) { + console.error(`Failed executing remote printer service over MQTT payload request: ${err.message}`); } - return res.status(502).send(`// go2rtc client error: ${err.message}\n`); - } -}); - -// ── Config check endpoint ──────────────────────────────────────────────────── -app.get('/api/config', (req, res) => { - res.json({ - configured: !!(PRINTER_IP && SERIAL_NUMBER && CHECK_CODE), - printerIp: PRINTER_IP || null, - go2rtcConfigured: !!(GO2RTC_URL && GO2RTC_STREAM), - go2rtcStream: GO2RTC_STREAM || null, - ingressPath: INGRESS_PATH, }); -}); -// ── Serve index.html dynamically with injected INGRESS_PATH ───────────────── -// HA Ingress strips the path prefix before forwarding requests, so all backend -// routes work at /api/... as normal. However, browser-side fetch() calls use -// absolute paths (e.g. /api/status) which would bypass the ingress prefix. -// We inject window.INGRESS_PATH into the HTML so the frontend can prefix them. -const INDEX_HTML_PATH = path.join(__dirname, 'frontend', 'public', 'index.html'); -const indexHtmlBase = fs.readFileSync(INDEX_HTML_PATH, 'utf8'); - -function serveIndex(req, res) { - let headInject = ``; - if (GO2RTC_URL && GO2RTC_STREAM) { - headInject += `\n `; - } - const html = indexHtmlBase.replace('', ` ${headInject}\n`); - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.send(html); + mqttClient.on('error', (err) => console.error(`MQTT client framework internal error: ${err.message}`)); } -app.get('*', serveIndex); +/* ── HTTP & WebSockets Server Core initialization ───────────────────────── */ -// ── Start ──────────────────────────────────────────────────────────────────── const server = http.createServer(app); -server.listen(PORT, () => { - console.log(`FlashForge Dashboard (HA add-on) running on port ${PORT}`); - console.log(`Ingress path: ${INGRESS_PATH || '(none)'}`); - console.log(`Direct HTTP URL: http://:${PORT}`); - if (!PRINTER_IP || !SERIAL_NUMBER || !CHECK_CODE) { - console.warn('⚠ printer_ip, serial_number or check_code not set. Configure them in the HA add-on Configuration tab.'); - } - setupMqtt(); - if (MQTT_ENABLED) { - mqttPollingTimer = setInterval(() => { - refreshPrinterState(); - }, MQTT_POLL_INTERVAL_MS); - } -}); - +// Infallibile WebSocket Proxy Tunnel per gestire lo stream di Frigate (go2rtc) sotto Ingress server.on('upgrade', (req, socket, head) => { - let urlPath = req.url || '/'; - if (INGRESS_PATH && urlPath.startsWith(INGRESS_PATH)) { - urlPath = urlPath.substring(INGRESS_PATH.length) || '/'; - } - - let urlObj; - try { - urlObj = new URL(urlPath, 'http://localhost'); - } catch (_) { - socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); + // Controllo elastico: basta che l'URL contenga il nostro path, ignorando i prefissi dinamici dell'Ingress + if (!req.url.includes('go2rtc/ws')) { socket.destroy(); return; } - if (!urlObj.pathname.startsWith('/api/go2rtc/ws')) { - socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); - socket.destroy(); - return; - } - - const streamName = urlObj.searchParams.get('src') || GO2RTC_STREAM; + // Estraiamo il corretto parametro 'src' analizzando la query string finale + const queryIdx = req.url.indexOf('?'); + const searchParams = new URLSearchParams(queryIdx !== -1 ? req.url.substring(queryIdx) : ''); + const streamName = searchParams.get('src') || GO2RTC_STREAM; + const targetPath = `/api/ws?src=${encodeURIComponent(streamName)}`; - const wsKey = req.headers['sec-websocket-key']; - if (!wsKey) { - socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); - socket.destroy(); - return; - } - - const proxySocket = net.connect(GO2RTC_UPSTREAM_PORT, GO2RTC_UPSTREAM_HOST, () => { - const wsVersion = req.headers['sec-websocket-version'] || '13'; - const wsProtocol = req.headers['sec-websocket-protocol']; - const wsExtensions = req.headers['sec-websocket-extensions']; - const origin = req.headers.origin; - const userAgent = req.headers['user-agent']; + const targetUrl = new URL(GO2RTC_URL); + const targetHost = targetUrl.hostname; + const targetPort = targetUrl.port || 1984; + const wsKey = req.headers['sec-websocket-key']; + const wsVersion = req.headers['sec-websocket-version'] || '13'; + const wsProtocol = req.headers['sec-websocket-protocol']; + const wsExtensions = req.headers['sec-websocket-extensions']; + const origin = req.headers['origin']; + const userAgent = req.headers['user-agent']; + + // Apriamo una connessione TCP nativa verso l'add-on Frigate + const proxySocket = net.net || net.connect(targetPort, targetHost, () => { const lines = [ `GET ${targetPath} HTTP/1.1`, - `Host: ${GO2RTC_UPSTREAM_HOST_HEADER}`, + `Host: ${GO2RTC_UPSTREAM_HOST_HEADER}`, // Forza l'header richiesto da Frigate 'Upgrade: websocket', 'Connection: Upgrade', `Sec-WebSocket-Key: ${wsKey}`, @@ -743,6 +286,10 @@ server.on('upgrade', (req, socket, head) => { proxySocket.pipe(socket); }); +server.listen(PORT, () => { + console.log(`FlashForge Dashboard Backend Add-on listening inside Docker container on port ${PORT}`); +}); + function shutdown() { if (mqttPollingTimer) { clearInterval(mqttPollingTimer); @@ -757,8 +304,9 @@ function shutdown() { } } server.close(() => process.exit(0)); - setTimeout(() => process.exit(0), 3000).unref(); + setTimeout(() => process.exit(1), 2000); } process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); + From 416f8153f102ccc374b6d901ae9975362d5c49da Mon Sep 17 00:00:00 2001 From: MikManenti <152449131+MikManenti@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:17:26 +0200 Subject: [PATCH 46/70] Fix formatting and update known MQTT command payloads --- flashforge-dashboard/server.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 5d00f0b..14d6122 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -16,12 +16,14 @@ const PORT = process.env.PORT || 8099; const PRINTER_IP = process.env.PRINTER_IP; const SERIAL_NUMBER = process.env.SERIAL_NUMBER; const CHECK_CODE = process.env.CHECK_CODE; -const KNOWN_MQTT_COMMAND_PAYLOADS = new Set([\n '1', 'ON', 'TRUE', 'OPEN', 'PAUSE', 'STOP', 'CLEAR', 'PRESS', 'RESUME', 'CONTINUE', 'CLOSE', '0', 'OFF', 'FALSE',\n]); +const KNOWN_MQTT_COMMAND_PAYLOADS = new Set([ + '1', 'ON', 'TRUE', 'OPEN', 'PAUSE', 'STOP', 'CLEAR', 'PRESS', 'RESUME', 'CONTINUE', 'CLOSE', '0', 'OFF', 'FALSE', +]); // HA Ingress sets this env var to the URL prefix it uses when proxying // (e.g. "/api/hassio_ingress/abc123"). The frontend needs this to build // correct absolute URLs for fetch() calls and the camera stream. -const INGRESS_PATH = (process.env.INGRESS_PATH || '').replace(/\\/$/, ''); +const INGRESS_PATH = (process.env.INGRESS_PATH || '').replace(/\/$/, ''); const PRINTER_API = `http://${PRINTER_IP}:8898`; const GO2RTC_URL = (process.env.GO2RTC_URL || 'http://ccab4aaf-frigate:1984').replace(/\/$/, ''); @@ -249,7 +251,7 @@ server.on('upgrade', (req, socket, head) => { const userAgent = req.headers['user-agent']; // Apriamo una connessione TCP nativa verso l'add-on Frigate - const proxySocket = net.net || net.connect(targetPort, targetHost, () => { + const proxySocket = net.connect(targetPort, targetHost, () => { const lines = [ `GET ${targetPath} HTTP/1.1`, `Host: ${GO2RTC_UPSTREAM_HOST_HEADER}`, // Forza l'header richiesto da Frigate @@ -309,4 +311,3 @@ function shutdown() { process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); - From 6214e365b21acb123afd97f9ba2db0c15138361a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:22:05 +0000 Subject: [PATCH 47/70] Restore server.js: MQTT Discovery, Ingress injection, correct API routes --- flashforge-dashboard/server.js | 805 +++++++++++++++++++++++++-------- 1 file changed, 628 insertions(+), 177 deletions(-) diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 14d6122..0bfd336 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -28,233 +28,688 @@ const INGRESS_PATH = (process.env.INGRESS_PATH || '').replace(/\/$/, ''); const PRINTER_API = `http://${PRINTER_IP}:8898`; const GO2RTC_URL = (process.env.GO2RTC_URL || 'http://ccab4aaf-frigate:1984').replace(/\/$/, ''); const GO2RTC_STREAM = (process.env.GO2RTC_STREAM || 'Stampante').trim(); +const MQTT_ENABLED = parseBooleanEnv(process.env.MQTT_ENABLED, true); +const MQTT_HOST = process.env.MQTT_HOST || 'core-mosquitto'; +const MQTT_PORT = Number(process.env.MQTT_PORT || 1883); +const MQTT_USERNAME = process.env.MQTT_USERNAME || ''; +const MQTT_PASSWORD = process.env.MQTT_PASSWORD || ''; +const MQTT_BASE_TOPIC = sanitizeTopic(process.env.MQTT_BASE_TOPIC || 'flashforge'); +const MQTT_POLL_INTERVAL_MS = 10000; -// Target host header dedicated for Frigate/go2rtc internal docker network -const GO2RTC_UPSTREAM_HOST_HEADER = 'ccab4aaf-frigate:1984'; +const DEVICE_ID = String(SERIAL_NUMBER || PRINTER_IP || 'flashforge_printer') + .replace(/[^\w-]/g, '_') + .toLowerCase(); +const DEVICE_NAME = SERIAL_NUMBER ? `FlashForge ${SERIAL_NUMBER}` : 'FlashForge Printer'; +const MQTT_ROOT_TOPIC = `${MQTT_BASE_TOPIC}/${DEVICE_ID}`; +const MQTT_AVAILABILITY_TOPIC = `${MQTT_ROOT_TOPIC}/availability`; -// Use memory storage for tiny G-code file uploads to avoid wearing out SD card storage. -const storage = multer.memoryStorage(); -const upload = multer({ - storage: storage, - limits: { fileSize: 150 * 1024 * 1024 } // 150MB maximum -}); +let mqttClient = null; +let mqttConnected = false; +let mqttDiscoveryPublished = false; +let lastPrinterDetail = null; +let cameraSwitchState = 'OFF'; +let mqttPollingTimer = null; -// Serve frontend static files -app.use(express.static(path.join(__dirname, 'frontend'))); +// ── Middleware ────────────────────────────────────────────────────────────── app.use(express.json()); +app.use(express.static(path.join(__dirname, 'frontend', 'public'))); -/* ── API Routes ──────────────────────────────────────────────────────────── */ +// multer: store upload in memory, then stream to printer +const upload = multer({ storage: multer.memoryStorage() }); -// Returns runtime options needed by the frontend dashboard. -app.get('/api/config', (req, res) => { - res.json({ - configured: !!(PRINTER_IP && SERIAL_NUMBER && CHECK_CODE), - ingressPath: INGRESS_PATH, - go2rtcStream: GO2RTC_STREAM - }); -}); +// ── Helpers ───────────────────────────────────────────────────────────────── -// Proxy route to pull go2rtc's official front-end client script safely through the add-on. -app.get('/api/go2rtc/client.js', async (req, res) => { +/** + * POST to the printer's HTTP REST API with standard auth fields. + * Times out after PRINTER_TIMEOUT_MS to avoid hanging indefinitely. + */ +const PRINTER_TIMEOUT_MS = 8000; + +async function printerPost(endpoint, body = {}) { + const payload = { serialNumber: SERIAL_NUMBER, checkCode: CHECK_CODE, ...body }; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), PRINTER_TIMEOUT_MS); try { - const targetUrl = `${GO2RTC_URL}/api/client.js`; - const response = await fetch(targetUrl, { - headers: { - 'Host': GO2RTC_UPSTREAM_HOST_HEADER, - 'User-Agent': req.headers['user-agent'] || 'HomeAssistantAddon' - }, - timeout: 5000 + const res = await fetch(`${PRINTER_API}${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: controller.signal, }); - - if (!response.ok) { - throw new Error(`Upstream go2rtc returned status ${response.status}`); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Printer returned ${res.status}: ${text}`); } - - const jsContent = await response.text(); - res.type('application/javascript').send(jsContent); + return res.json(); } catch (err) { - console.error(`Error proxying go2rtc client.js: ${err.message}`); - res.status(502).send(`/* go2rtc client proxy error: ${err.message} */`); + if (err.name === 'AbortError') { + throw new Error(`Printer did not respond within ${PRINTER_TIMEOUT_MS / 1000}s (${PRINTER_API})`); + } + throw err; + } finally { + clearTimeout(timer); } -}); +} + +async function printerControl(cmd, args = {}) { + return printerPost('/control', { + payload: { + cmd, + args, + }, + }); +} -// Proxy standard printer API JSON endpoints transparently -app.get('/api/printer/*', async (req, res) => { - const subPath = req.params[0]; - const queryString = req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : ''; +function sanitizeTopic(topic) { + return String(topic || 'flashforge') + .trim() + .replace(/^[\/\s]+|[\/\s]+$/g, '') + .replace(/\s+/g, '_'); +} + +function parseBooleanEnv(value, defaultValue = false) { + if (value === undefined || value === null || value === '') return defaultValue; + const normalized = String(value).trim().toLowerCase(); + if (['true', '1', 'yes', 'on'].includes(normalized)) return true; + if (['false', '0', 'no', 'off'].includes(normalized)) return false; + return defaultValue; +} + +function mqttPublish(topic, payload, options = {}) { + if (!mqttClient || !mqttConnected) return; + mqttClient.publish(topic, String(payload), { qos: 0, retain: false, ...options }); +} + +function getCurrentJobId() { + if (!lastPrinterDetail || !Array.isArray(lastPrinterDetail.jobInfo)) return ''; + return (lastPrinterDetail.jobInfo[0] && lastPrinterDetail.jobInfo[0][1]) || ''; +} + +function publishMqttState(detail) { + if (!detail || !mqttConnected) return; + + const normalizedStatus = String(detail.status || 'ready').trim().toUpperCase(); + const progress = detail.printProgress != null ? Math.round(detail.printProgress * 100) : 0; + const isPrinting = ['PRINTING', 'BUSY', 'HEATING', 'PAUSED', 'PAUSING'].includes(normalizedStatus); + const pauseSwitchState = ['PAUSED', 'PAUSING'].includes(normalizedStatus) ? 'ON' : 'OFF'; + + mqttPublish(`${MQTT_ROOT_TOPIC}/state/status`, normalizedStatus, { retain: true }); + mqttPublish(`${MQTT_ROOT_TOPIC}/state/progress`, progress, { retain: true }); + mqttPublish(`${MQTT_ROOT_TOPIC}/state/file_name`, detail.printFileName || '', { retain: true }); + mqttPublish(`${MQTT_ROOT_TOPIC}/state/nozzle_temp`, detail.rightTemp ?? '', { retain: true }); + mqttPublish(`${MQTT_ROOT_TOPIC}/state/bed_temp`, detail.platTemp ?? '', { retain: true }); + mqttPublish(`${MQTT_ROOT_TOPIC}/state/chamber_temp`, detail.chamberTemp ?? '', { retain: true }); + mqttPublish(`${MQTT_ROOT_TOPIC}/state/estimated_time_s`, detail.estimatedTime ?? '', { retain: true }); + mqttPublish(`${MQTT_ROOT_TOPIC}/state/layer_current`, detail.printLayer ?? '', { retain: true }); + mqttPublish(`${MQTT_ROOT_TOPIC}/state/layer_target`, detail.targetPrintLayer ?? '', { retain: true }); + mqttPublish(`${MQTT_ROOT_TOPIC}/state/is_printing`, isPrinting ? 'ON' : 'OFF', { retain: true }); + mqttPublish(`${MQTT_ROOT_TOPIC}/state/pause_switch`, pauseSwitchState, { retain: true }); + mqttPublish(`${MQTT_ROOT_TOPIC}/state/camera_switch`, cameraSwitchState, { retain: true }); +} + +function updatePrinterDetail(detail) { + if (!detail) return; + lastPrinterDetail = detail; + publishMqttState(detail); +} + +function createMqttDeviceInfo() { + return { + identifiers: [DEVICE_ID], + name: DEVICE_NAME, + manufacturer: 'FlashForge', + model: 'AD5 Series', + }; +} + +function publishMqttDiscovery() { + if (!mqttConnected || mqttDiscoveryPublished) return; + const device = createMqttDeviceInfo(); + const discoveryBase = 'homeassistant'; + const publishDiscovery = (component, objectId, payload) => { + const topic = `${discoveryBase}/${component}/${DEVICE_ID}/${objectId}/config`; + mqttPublish(topic, JSON.stringify(payload), { retain: true }); + }; + + publishDiscovery('sensor', 'status', { + name: 'Status', + unique_id: `${DEVICE_ID}_status`, + state_topic: `${MQTT_ROOT_TOPIC}/state/status`, + icon: 'mdi:printer-3d-nozzle-alert', + availability_topic: MQTT_AVAILABILITY_TOPIC, + device, + }); + publishDiscovery('sensor', 'progress', { + name: 'Progress', + unique_id: `${DEVICE_ID}_progress`, + state_topic: `${MQTT_ROOT_TOPIC}/state/progress`, + unit_of_measurement: '%', + icon: 'mdi:progress-clock', + availability_topic: MQTT_AVAILABILITY_TOPIC, + device, + }); + publishDiscovery('sensor', 'nozzle_temp', { + name: 'Nozzle Temperature', + unique_id: `${DEVICE_ID}_nozzle_temp`, + state_topic: `${MQTT_ROOT_TOPIC}/state/nozzle_temp`, + unit_of_measurement: '°C', + device_class: 'temperature', + availability_topic: MQTT_AVAILABILITY_TOPIC, + device, + }); + publishDiscovery('sensor', 'bed_temp', { + name: 'Bed Temperature', + unique_id: `${DEVICE_ID}_bed_temp`, + state_topic: `${MQTT_ROOT_TOPIC}/state/bed_temp`, + unit_of_measurement: '°C', + device_class: 'temperature', + availability_topic: MQTT_AVAILABILITY_TOPIC, + device, + }); + publishDiscovery('sensor', 'chamber_temp', { + name: 'Chamber Temperature', + unique_id: `${DEVICE_ID}_chamber_temp`, + state_topic: `${MQTT_ROOT_TOPIC}/state/chamber_temp`, + unit_of_measurement: '°C', + device_class: 'temperature', + availability_topic: MQTT_AVAILABILITY_TOPIC, + device, + }); + publishDiscovery('sensor', 'estimated_time', { + name: 'Estimated Time', + unique_id: `${DEVICE_ID}_estimated_time_s`, + state_topic: `${MQTT_ROOT_TOPIC}/state/estimated_time_s`, + unit_of_measurement: 's', + icon: 'mdi:timer-outline', + availability_topic: MQTT_AVAILABILITY_TOPIC, + device, + }); + publishDiscovery('binary_sensor', 'is_printing', { + name: 'Printing', + unique_id: `${DEVICE_ID}_is_printing`, + state_topic: `${MQTT_ROOT_TOPIC}/state/is_printing`, + payload_on: 'ON', + payload_off: 'OFF', + availability_topic: MQTT_AVAILABILITY_TOPIC, + device, + }); + publishDiscovery('switch', 'pause_resume', { + name: 'Pause Print', + unique_id: `${DEVICE_ID}_pause_resume`, + state_topic: `${MQTT_ROOT_TOPIC}/state/pause_switch`, + command_topic: `${MQTT_ROOT_TOPIC}/command/pause_resume`, + payload_on: 'PAUSE', + payload_off: 'RESUME', + state_on: 'ON', + state_off: 'OFF', + icon: 'mdi:pause-circle', + availability_topic: MQTT_AVAILABILITY_TOPIC, + device, + }); + publishDiscovery('switch', 'camera', { + name: 'Camera Stream', + unique_id: `${DEVICE_ID}_camera_stream`, + state_topic: `${MQTT_ROOT_TOPIC}/state/camera_switch`, + command_topic: `${MQTT_ROOT_TOPIC}/command/camera`, + payload_on: 'OPEN', + payload_off: 'CLOSE', + state_on: 'ON', + state_off: 'OFF', + icon: 'mdi:cctv', + availability_topic: MQTT_AVAILABILITY_TOPIC, + device, + }); + publishDiscovery('button', 'stop', { + name: 'Stop Print', + unique_id: `${DEVICE_ID}_stop`, + command_topic: `${MQTT_ROOT_TOPIC}/command/stop`, + payload_press: 'STOP', + icon: 'mdi:stop-circle', + availability_topic: MQTT_AVAILABILITY_TOPIC, + device, + }); + publishDiscovery('button', 'clear_state', { + name: 'Clear Printer State', + unique_id: `${DEVICE_ID}_clear_state`, + command_topic: `${MQTT_ROOT_TOPIC}/command/clear_state`, + payload_press: 'CLEAR', + icon: 'mdi:broom', + availability_topic: MQTT_AVAILABILITY_TOPIC, + device, + }); + + mqttDiscoveryPublished = true; +} + +async function refreshPrinterState() { + if (!PRINTER_IP || !SERIAL_NUMBER || !CHECK_CODE) return; try { - const targetUrl = `${PRINTER_API}/${subPath}${queryString}`; - const response = await fetch(targetUrl, { timeout: 3000 }); - if (!response.ok) return res.status(response.status).send(await response.text()); - res.json(await response.json()); + const data = await printerPost('/detail'); + if (data && data.detail) { + updatePrinterDetail(data.detail); + } } catch (err) { - res.status(504).json({ code: -1, message: `Printer connection failed: ${err.message}` }); + console.warn(`MQTT state refresh failed: ${err.message}`); } -}); +} -// Handle G-code uploads and pipe them as multipart data directly to FlashForge native network API -app.post('/api/printer/upload', upload.single('file'), async (req, res) => { - if (!req.file) return res.status(400).json({ code: -1, message: 'No file uploaded.' }); +function isKnownCommandPayload(payload) { + return KNOWN_MQTT_COMMAND_PAYLOADS.has(payload); +} - try { - const printNow = req.body.printNow === 'true' ? '1' : '0'; - const leveling = req.body.leveling === 'true' ? '1' : '0'; - - const boundary = `----WebKitFormBoundary${Math.random().toString(36).substring(2)}`; - const header = - `--${boundary}\r\n` + - `Content-Disposition: form-data; name="printNow"\r\n\r\n${printNow}\r\n` + - `--${boundary}\r\n` + - `Content-Disposition: form-data; name="leveling"\r\n\r\n${leveling}\r\n` + - `--${boundary}\r\n` + - `Content-Disposition: form-data; name="file"; filename="${req.file.originalname}"\r\n` + - `Content-Type: application/octet-stream\r\n\r\n`; - const footer = `\r\n--${boundary}--\r\n`; - - const payloadBuffer = Buffer.concat([ - Buffer.from(header, 'utf-8'), - req.file.buffer, - Buffer.from(footer, 'utf-8') - ]); - - const response = await fetch(`${PRINTER_API}/upload`, { - method: 'POST', - headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` }, - body: payloadBuffer, - timeout: 180000 // 3 minutes timeout for massive files - }); +async function handleMqttCommand(topic, payloadRaw) { + const payload = String(payloadRaw || '').trim().toUpperCase(); + if (!payload || !isKnownCommandPayload(payload)) return; - if (!response.ok) return res.status(response.status).send(await response.text()); - res.json(await response.json()); - } catch (err) { - res.status(504).json({ code: -1, message: `Upload payload transmission failed: ${err.message}` }); + if (topic === `${MQTT_ROOT_TOPIC}/command/camera`) { + const action = ['OPEN', 'ON', '1', 'TRUE'].includes(payload) ? 'open' : 'close'; + cameraSwitchState = action === 'open' ? 'ON' : 'OFF'; + mqttPublish(`${MQTT_ROOT_TOPIC}/state/camera_switch`, cameraSwitchState, { retain: true }); + return; } -}); -/* ── MQTT Integration ────────────────────────────────────────────────────── */ -const MQTT_ENABLED = process.env.MQTT_ENABLED === 'true'; -const MQTT_HOST = process.env.MQTT_HOST || 'core-mosquitto'; -const MQTT_PORT = parseInt(process.env.MQTT_PORT || '1883', 10); -const MQTT_USERNAME = process.env.MQTT_USERNAME || ''; -const MQTT_PASSWORD = process.env.MQTT_PASSWORD || ''; -const MQTT_BASE_TOPIC = (process.env.MQTT_BASE_TOPIC || 'flashforge').replace(/\/$/, ''); - -const MQTT_STATE_TOPIC = `${MQTT_BASE_TOPIC}/state`; -const MQTT_AVAILABILITY_TOPIC = `${MQTT_BASE_TOPIC}/availability`; -const MQTT_COMMAND_TOPIC = `${MQTT_BASE_TOPIC}/command`; + if (topic === `${MQTT_ROOT_TOPIC}/command/pause_resume`) { + const action = ['PAUSE', 'ON', '1', 'TRUE'].includes(payload) ? 'pause' : 'continue'; + await printerControl('jobCtl_cmd', { jobID: getCurrentJobId(), action }); + await refreshPrinterState(); + return; + } -let mqttClient = null; -let mqttPollingTimer = null; + if (topic === `${MQTT_ROOT_TOPIC}/command/stop`) { + if (!['STOP', 'PRESS', 'ON', '1', 'TRUE'].includes(payload)) return; + await printerControl('jobCtl_cmd', { jobID: getCurrentJobId(), action: 'cancel' }); + await refreshPrinterState(); + return; + } -function mqttPublish(topic, payload, options = {}) { - if (!mqttClient || !mqttClient.connected) return; - const msg = typeof payload === 'object' ? JSON.stringify(payload) : String(payload); - mqttClient.publish(topic, msg, options, (err) => { - if (err) console.warn(`MQTT Publish failed on ${topic}: ${err.message}`); - }); + if (topic === `${MQTT_ROOT_TOPIC}/command/clear_state`) { + if (!['CLEAR', 'PRESS', 'ON', '1', 'TRUE'].includes(payload)) return; + await printerControl('stateCtrl_cmd', { action: 'setClearPlatform' }); + await refreshPrinterState(); + } } -if (MQTT_ENABLED && PRINTER_IP) { - const mqttOptions = { - port: MQTT_PORT, - clean: true, - connectTimeout: 5000, - reconnectPeriod: 10000, +function setupMqtt() { + if (!MQTT_ENABLED) { + console.log('MQTT disabled via configuration.'); + return; + } + + const mqttUrl = `mqtt://${MQTT_HOST}:${MQTT_PORT}`; + const options = { + reconnectPeriod: 5000, will: { topic: MQTT_AVAILABILITY_TOPIC, payload: 'offline', - qos: 1, - retain: true - } + retain: true, + }, }; - if (MQTT_USERNAME) mqttOptions.username = MQTT_USERNAME; - if (MQTT_PASSWORD) mqttOptions.password = MQTT_PASSWORD; + if (MQTT_USERNAME) options.username = MQTT_USERNAME; + if (MQTT_PASSWORD) options.password = MQTT_PASSWORD; - console.log(`Connecting to MQTT broker at mqtt://${MQTT_HOST}:${MQTT_PORT}...`); - mqttClient = mqtt.connect(`mqtt://${MQTT_HOST}`, mqttOptions); + mqttClient = mqtt.connect(mqttUrl, options); - mqttClient.on('connect', () => { - console.log('MQTT Broker connected successfully.'); + mqttClient.on('connect', async () => { + mqttConnected = true; + mqttDiscoveryPublished = false; + console.log(`Connected to MQTT broker at ${mqttUrl}`); mqttPublish(MQTT_AVAILABILITY_TOPIC, 'online', { retain: true }); - - mqttClient.subscribe(MQTT_COMMAND_TOPIC, { qos: 1 }, (err) => { - if (err) console.error(`MQTT fails to subscribe to command topic: ${err.message}`); - else console.log(`Subscribed to MQTT command topic: ${MQTT_COMMAND_TOPIC}`); + publishMqttDiscovery(); + await refreshPrinterState(); + + const commandTopics = [ + `${MQTT_ROOT_TOPIC}/command/camera`, + `${MQTT_ROOT_TOPIC}/command/pause_resume`, + `${MQTT_ROOT_TOPIC}/command/stop`, + `${MQTT_ROOT_TOPIC}/command/clear_state`, + ]; + mqttClient.subscribe(commandTopics, (err) => { + if (err) { + console.warn(`MQTT subscribe error: ${err.message}`); + } }); + }); - // Start background background cyclic daemon polling printer state onto HA MQTT bus - if (mqttPollingTimer) clearInterval(mqttPollingTimer); - mqttPollingTimer = setInterval(async () => { + mqttClient.on('message', async (topic, payload) => { + try { + await handleMqttCommand(topic, payload); + } catch (err) { + console.warn(`MQTT command error on ${topic}: ${err.message}`); + } + }); + + mqttClient.on('error', (err) => { + console.warn(`MQTT error: ${err.message}`); + }); + + mqttClient.on('close', () => { + mqttConnected = false; + }); +} + +/** + * Validate that required env vars are set and return 503 otherwise. + */ +function requireConfig(req, res, next) { + if (!PRINTER_IP || !SERIAL_NUMBER || !CHECK_CODE) { + return res.status(503).json({ + error: 'Printer not configured. Set printer_ip, serial_number and check_code in the add-on Configuration tab.', + }); + } + next(); +} + +// ── API Routes ─────────────────────────────────────────────────────────────── + +/** + * GET /api/status + * Returns the full detail response from the printer. + */ +app.get('/api/status', requireConfig, async (req, res) => { + try { + const data = await printerPost('/detail'); + if (data && data.detail) { + updatePrinterDetail(data.detail); + } + res.json(data); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +/** + * POST /api/control + * Body: { action: "pause"|"continue"|"cancel", jobID?: "..." } + */ +app.post('/api/control', requireConfig, async (req, res) => { + const { action, jobID } = req.body; + if (!action) { + return res.status(400).json({ error: 'action is required' }); + } + try { + const data = await printerControl('jobCtl_cmd', { jobID: jobID || '', action }); + await refreshPrinterState(); + res.json(data); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +app.post('/api/state/clear', requireConfig, async (req, res) => { + try { + const data = await printerControl('stateCtrl_cmd', { action: 'setClearPlatform' }); + await refreshPrinterState(); + res.json(data); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +/** + * GET /api/files + * Returns the list of printable files stored on the printer. + */ +app.get('/api/files', requireConfig, async (req, res) => { + try { + const data = await printerPost('/gcodeList'); + res.json(data); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +/** + * GET /api/thumb?fileName=... + * Returns base64 thumbnail for a file. + */ +app.get('/api/thumb', requireConfig, async (req, res) => { + const { fileName } = req.query; + if (!fileName) return res.status(400).json({ error: 'fileName is required' }); + try { + const data = await printerPost('/gcodeThumb', { fileName }); + res.json(data); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +/** + * POST /api/print + * Body: { fileName: "...", levelingBeforePrint: true|false } + * Starts printing a file already stored on the printer. + */ +app.post('/api/print', requireConfig, async (req, res) => { + const { fileName, levelingBeforePrint = false } = req.body; + if (!fileName) return res.status(400).json({ error: 'fileName is required' }); + try { + const data = await printerPost('/printGcode', { fileName, levelingBeforePrint }); + await refreshPrinterState(); + res.json(data); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +/** + * POST /api/upload + * Multipart form with field "gcodeFile". + * Optional form fields: printNow (0|1), levelingBeforePrint (0|1) + */ +app.post('/api/upload', requireConfig, upload.single('gcodeFile'), async (req, res) => { + if (!req.file) return res.status(400).json({ error: 'gcodeFile is required' }); + + const printNow = req.body.printNow || '0'; + const levelingBeforePrint = req.body.levelingBeforePrint || '0'; + const fileSize = req.file.size; + + // Build a multipart body to forward to the printer + const boundary = `----FormBoundary${Date.now()}`; + const preamble = [ + `--${boundary}`, + `Content-Disposition: form-data; name="gcodeFile"; filename="${req.file.originalname}"`, + `Content-Type: application/octet-stream`, + '', + '', + ].join('\r\n'); + const epilogue = `\r\n--${boundary}--\r\n`; + + const body = Buffer.concat([ + Buffer.from(preamble), + req.file.buffer, + Buffer.from(epilogue), + ]); + + const printerRes = await fetch(`${PRINTER_API}/uploadGcode`, { + method: 'POST', + headers: { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + serialNumber: SERIAL_NUMBER, + checkCode: CHECK_CODE, + fileSize: String(fileSize), + printNow, + levelingBeforePrint, + }, + body, + }); + + const result = await printerRes.json().catch(() => ({ code: printerRes.status })); + if (printerRes.ok && result.code === 0) { + if (printNow === '1') { try { - const data = await fetch(`${PRINTER_API}/get_status`, { timeout: 2000 }).then(r => r.json()); - if (data && data.code === 0) { - mqttPublish(MQTT_STATE_TOPIC, data.data); - mqttPublish(MQTT_AVAILABILITY_TOPIC, 'online', { retain: true }); - } else { - mqttPublish(MQTT_AVAILABILITY_TOPIC, 'offline', { retain: true }); - } - } catch (_) { - mqttPublish(MQTT_AVAILABILITY_TOPIC, 'offline', { retain: true }); + await printerPost('/printGcode', { + fileName: req.file.originalname, + levelingBeforePrint: levelingBeforePrint === '1', + }); + } catch (err) { + // Upload succeeded; report the print-start failure without blocking the response + await refreshPrinterState(); + return res.status(200).json({ code: 0, printStartError: err.message }); } - }, 4000); - }); + } + await refreshPrinterState(); + } + res.status(printerRes.ok ? 200 : 502).json(result); +}); + +/** + * POST /api/camera + * Body: { action: "open"|"close" } + * Tracks camera switch state (the stream itself is provided by HA). + */ +app.post('/api/camera', requireConfig, async (req, res) => { + const { action } = req.body; + if (!action) return res.status(400).json({ error: 'action is required' }); + cameraSwitchState = action === 'open' ? 'ON' : 'OFF'; + mqttPublish(`${MQTT_ROOT_TOPIC}/state/camera_switch`, cameraSwitchState, { retain: true }); + res.json({}); +}); - mqttClient.on('message', async (topic, message) => { - if (topic !== MQTT_COMMAND_TOPIC) return; - const rawPayload = message.toString().trim().toUpperCase(); - if (!KNOWN_MQTT_COMMAND_PAYLOADS.has(rawPayload)) return; +// ── go2rtc integration ─────────────────────────────────────────────────────── - try { - let endpoint = null; - if (['1', 'ON', 'TRUE', 'OPEN', 'RESUME', 'CONTINUE'].includes(rawPayload)) endpoint = 'resume_print'; - else if (['PAUSE'].includes(rawPayload)) endpoint = 'pause_print'; - else if (['0', 'OFF', 'FALSE', 'STOP', 'CLEAR', 'PRESS', 'CLOSE'].includes(rawPayload)) endpoint = 'stop_print'; - - if (!endpoint) return; - console.log(`MQTT Command execution received: ${rawPayload} -> Invoking ${endpoint}`); - await fetch(`${PRINTER_API}/${endpoint}`, { method: 'POST', timeout: 3000 }); - } catch (err) { - console.error(`Failed executing remote printer service over MQTT payload request: ${err.message}`); +/** Server-side cache for go2rtc client script to avoid fetching it on every page load. */ +let cachedVideoRtcJs = null; +let cachedVideoRtcJsAt = 0; +const VIDEO_RTC_CACHE_TTL_MS = 3600000; // 1 hour + +const GO2RTC_UPSTREAM_HOST = 'ccab4aaf-frigate'; +const GO2RTC_UPSTREAM_PORT = 1984; +const GO2RTC_UPSTREAM_HOST_HEADER = `${GO2RTC_UPSTREAM_HOST}:${GO2RTC_UPSTREAM_PORT}`; +const GO2RTC_CLIENT_CANDIDATE_PATHS = ['/api/go2rtc/client.js', '/video-rtc.js']; + +/** + * GET /api/go2rtc/client.js + * Proxies the go2rtc client JS so the browser can load it from the same origin. + */ +app.get('/api/go2rtc/client.js', async (req, res) => { + if (!GO2RTC_URL) { + return res.status(503).send('// go2rtc_url not configured\n'); + } + + const now = Date.now(); + if (cachedVideoRtcJs && (now - cachedVideoRtcJsAt) < VIDEO_RTC_CACHE_TTL_MS) { + res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); + res.setHeader('Cache-Control', 'max-age=3600'); + return res.send(cachedVideoRtcJs); + } + + try { + for (const candidatePath of GO2RTC_CLIENT_CANDIDATE_PATHS) { + const upstream = await fetch(`${GO2RTC_URL}${candidatePath}`, { + headers: { Host: GO2RTC_UPSTREAM_HOST_HEADER }, + }); + if (!upstream.ok) continue; + + cachedVideoRtcJs = await upstream.text(); + cachedVideoRtcJsAt = now; + res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); + res.setHeader('Cache-Control', 'max-age=3600'); + return res.send(cachedVideoRtcJs); } + + if (cachedVideoRtcJs) { + res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); + return res.send(cachedVideoRtcJs); + } + + return res.status(502).send('// go2rtc client not available from upstream\n'); + } catch (err) { + if (cachedVideoRtcJs) { + res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); + return res.send(cachedVideoRtcJs); + } + return res.status(502).send(`// go2rtc client error: ${err.message}\n`); + } +}); + +// ── Config check endpoint ──────────────────────────────────────────────────── +app.get('/api/config', (req, res) => { + res.json({ + configured: !!(PRINTER_IP && SERIAL_NUMBER && CHECK_CODE), + printerIp: PRINTER_IP || null, + go2rtcConfigured: !!(GO2RTC_URL && GO2RTC_STREAM), + go2rtcStream: GO2RTC_STREAM || null, + ingressPath: INGRESS_PATH, }); +}); - mqttClient.on('error', (err) => console.error(`MQTT client framework internal error: ${err.message}`)); +// ── Serve index.html dynamically with injected INGRESS_PATH ───────────────── +// HA Ingress strips the path prefix before forwarding requests, so all backend +// routes work at /api/... as normal. However, browser-side fetch() calls use +// absolute paths (e.g. /api/status) which would bypass the ingress prefix. +// We inject window.INGRESS_PATH into the HTML so the frontend can prefix them. +const INDEX_HTML_PATH = path.join(__dirname, 'frontend', 'public', 'index.html'); +const indexHtmlBase = fs.readFileSync(INDEX_HTML_PATH, 'utf8'); + +function serveIndex(req, res) { + let headInject = ``; + if (GO2RTC_URL && GO2RTC_STREAM) { + headInject += `\n `; + } + const html = indexHtmlBase.replace('', ` ${headInject}\n`); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(html); } -/* ── HTTP & WebSockets Server Core initialization ───────────────────────── */ +app.get('*', serveIndex); +// ── Start ──────────────────────────────────────────────────────────────────── const server = http.createServer(app); -// Infallibile WebSocket Proxy Tunnel per gestire lo stream di Frigate (go2rtc) sotto Ingress +server.listen(PORT, () => { + console.log(`FlashForge Dashboard (HA add-on) running on port ${PORT}`); + console.log(`Ingress path: ${INGRESS_PATH || '(none)'}`); + console.log(`Direct HTTP URL: http://:${PORT}`); + if (!PRINTER_IP || !SERIAL_NUMBER || !CHECK_CODE) { + console.warn('⚠ printer_ip, serial_number or check_code not set. Configure them in the HA add-on Configuration tab.'); + } + setupMqtt(); + if (MQTT_ENABLED) { + mqttPollingTimer = setInterval(() => { + refreshPrinterState(); + }, MQTT_POLL_INTERVAL_MS); + } +}); + server.on('upgrade', (req, socket, head) => { - // Controllo elastico: basta che l'URL contenga il nostro path, ignorando i prefissi dinamici dell'Ingress - if (!req.url.includes('go2rtc/ws')) { + let urlPath = req.url || '/'; + if (INGRESS_PATH && urlPath.startsWith(INGRESS_PATH)) { + urlPath = urlPath.substring(INGRESS_PATH.length) || '/'; + } + + let urlObj; + try { + urlObj = new URL(urlPath, 'http://localhost'); + } catch (_) { + socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); socket.destroy(); return; } - // Estraiamo il corretto parametro 'src' analizzando la query string finale - const queryIdx = req.url.indexOf('?'); - const searchParams = new URLSearchParams(queryIdx !== -1 ? req.url.substring(queryIdx) : ''); - const streamName = searchParams.get('src') || GO2RTC_STREAM; - - const targetPath = `/api/ws?src=${encodeURIComponent(streamName)}`; - const targetUrl = new URL(GO2RTC_URL); - const targetHost = targetUrl.hostname; - const targetPort = targetUrl.port || 1984; + if (!urlObj.pathname.startsWith('/api/go2rtc/ws')) { + socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); + socket.destroy(); + return; + } + const streamName = urlObj.searchParams.get('src') || GO2RTC_STREAM; + const targetPath = `/api/ws?src=${encodeURIComponent(streamName)}`; const wsKey = req.headers['sec-websocket-key']; - const wsVersion = req.headers['sec-websocket-version'] || '13'; - const wsProtocol = req.headers['sec-websocket-protocol']; - const wsExtensions = req.headers['sec-websocket-extensions']; - const origin = req.headers['origin']; - const userAgent = req.headers['user-agent']; - - // Apriamo una connessione TCP nativa verso l'add-on Frigate - const proxySocket = net.connect(targetPort, targetHost, () => { + if (!wsKey) { + socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); + socket.destroy(); + return; + } + + const proxySocket = net.connect(GO2RTC_UPSTREAM_PORT, GO2RTC_UPSTREAM_HOST, () => { + const wsVersion = req.headers['sec-websocket-version'] || '13'; + const wsProtocol = req.headers['sec-websocket-protocol']; + const wsExtensions = req.headers['sec-websocket-extensions']; + const origin = req.headers.origin; + const userAgent = req.headers['user-agent']; + const lines = [ `GET ${targetPath} HTTP/1.1`, - `Host: ${GO2RTC_UPSTREAM_HOST_HEADER}`, // Forza l'header richiesto da Frigate + `Host: ${GO2RTC_UPSTREAM_HOST_HEADER}`, 'Upgrade: websocket', 'Connection: Upgrade', `Sec-WebSocket-Key: ${wsKey}`, @@ -288,10 +743,6 @@ server.on('upgrade', (req, socket, head) => { proxySocket.pipe(socket); }); -server.listen(PORT, () => { - console.log(`FlashForge Dashboard Backend Add-on listening inside Docker container on port ${PORT}`); -}); - function shutdown() { if (mqttPollingTimer) { clearInterval(mqttPollingTimer); @@ -306,7 +757,7 @@ function shutdown() { } } server.close(() => process.exit(0)); - setTimeout(() => process.exit(1), 2000); + setTimeout(() => process.exit(0), 3000).unref(); } process.on('SIGTERM', shutdown); From 8c259c6df2bf2aa19c0a3bc6261bd1e5a884e24d Mon Sep 17 00:00:00 2001 From: MikManenti <152449131+MikManenti@users.noreply.github.com> Date: Sat, 20 Jun 2026 07:14:11 +0200 Subject: [PATCH 48/70] Add files via upload --- flashforge-dashboard/frontend/public/app.js | 13 +++++++++++++ flashforge-dashboard/frontend/public/index.html | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/flashforge-dashboard/frontend/public/app.js b/flashforge-dashboard/frontend/public/app.js index bdc0b1a..6ce38e3 100644 --- a/flashforge-dashboard/frontend/public/app.js +++ b/flashforge-dashboard/frontend/public/app.js @@ -14,6 +14,19 @@ const BASE = (window.INGRESS_PATH || detectedIngress || '').replace(/\/$/, ''); /** Stream name injected by server.js when go2rtc is configured, otherwise null. */ const GO2RTC_STREAM = window.GO2RTC_STREAM || null; + +/* ── Caricamento dinamico go2rtc per WebView Smartphone ──────────── */ +function initGo2RTC() { + const script = document.createElement('script'); + script.src = `${BASE}/api/go2rtc/client.js`; + script.defer = true; + script.onload = () => console.log('✅ go2rtc client.js caricato con successo via BASE dinamico.'); + script.onerror = () => console.error('❌ Errore nel caricamento di client.js.'); + document.head.appendChild(script); +} +initGo2RTC(); + + /* ── State ───────────────────────────────────────────────────────────────── */ let currentJobID = null; let currentStatus = null; diff --git a/flashforge-dashboard/frontend/public/index.html b/flashforge-dashboard/frontend/public/index.html index 607695c..8e68b99 100644 --- a/flashforge-dashboard/frontend/public/index.html +++ b/flashforge-dashboard/frontend/public/index.html @@ -7,7 +7,7 @@ - +
From a8e1bb1954da72deee81356ba32966bacca9d8f6 Mon Sep 17 00:00:00 2001 From: MikManenti <152449131+MikManenti@users.noreply.github.com> Date: Sat, 20 Jun 2026 07:34:50 +0200 Subject: [PATCH 49/70] Add files via upload --- flashforge-dashboard/frontend/public/app.js | 43 ++++++++----------- .../frontend/public/index.html | 2 +- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/flashforge-dashboard/frontend/public/app.js b/flashforge-dashboard/frontend/public/app.js index 6ce38e3..541d660 100644 --- a/flashforge-dashboard/frontend/public/app.js +++ b/flashforge-dashboard/frontend/public/app.js @@ -14,19 +14,6 @@ const BASE = (window.INGRESS_PATH || detectedIngress || '').replace(/\/$/, ''); /** Stream name injected by server.js when go2rtc is configured, otherwise null. */ const GO2RTC_STREAM = window.GO2RTC_STREAM || null; - -/* ── Caricamento dinamico go2rtc per WebView Smartphone ──────────── */ -function initGo2RTC() { - const script = document.createElement('script'); - script.src = `${BASE}/api/go2rtc/client.js`; - script.defer = true; - script.onload = () => console.log('✅ go2rtc client.js caricato con successo via BASE dinamico.'); - script.onerror = () => console.error('❌ Errore nel caricamento di client.js.'); - document.head.appendChild(script); -} -initGo2RTC(); - - /* ── State ───────────────────────────────────────────────────────────────── */ let currentJobID = null; let currentStatus = null; @@ -193,26 +180,21 @@ function updateUI(d) { * attribute and processes it once the element is upgraded. */ function initCamera() { - if (!GO2RTC_STREAM) { - console.log('Camera component skipped: GO2RTC_STREAM is not configured'); - cameraPlaceholder.classList.remove('hidden'); - cameraRtc.classList.remove('active'); - cameraImg.classList.remove('active'); - return; - } - + const streamName = window.GO2RTC_STREAM || 'Stampante'; cameraPlaceholder.classList.add('hidden'); cameraImg.classList.remove('active'); cameraRtc.classList.add('active'); const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${wsProto}//${window.location.host}${BASE}/api/go2rtc/ws?src=${encodeURIComponent(GO2RTC_STREAM)}`; - console.log(`Initializing video-stream target: ${wsUrl}`); + // Usiamo il percorso compatibile con il proxy definito in server.js + const wsUrl = `${wsProto}//${window.location.host}${BASE}/api/go2rtc/ws?src=${encodeURIComponent(streamName)}`; + + console.log(`[Camera] Requesting stream at: ${wsUrl}`); cameraRtc.setAttribute('src', wsUrl); } function disableCamera() { - if (cameraRtc.src) cameraRtc.src = ''; + cameraRtc.removeAttribute('src'); cameraRtc.classList.remove('active'); cameraImg.src = ''; cameraImg.classList.remove('active'); @@ -220,17 +202,26 @@ function disableCamera() { } btnCameraOn.addEventListener('click', async () => { - if (!GO2RTC_STREAM) return; + btnCameraOn.disabled = true; + console.log('[Camera] Waking up camera via API...'); try { await fetch(`${BASE}/api/camera`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'open' }) }); - } catch (_) { /* ignore – try to show stream anyway */ } + } catch (err) { + console.warn('[Camera] Wake API error, trying stream anyway', err); + } + initCamera(); + setTimeout(() => { btnCameraOn.disabled = false; }, 1000); }); btnCameraOff.addEventListener('click', async () => { + btnCameraOff.disabled = true; disableCamera(); try { await fetch(`${BASE}/api/camera`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'close' }) }); + } catch (_) { } + setTimeout(() => { btnCameraOff.disabled = false; }, 1000); +}); } catch (_) { /* ignore */ } }); diff --git a/flashforge-dashboard/frontend/public/index.html b/flashforge-dashboard/frontend/public/index.html index 8e68b99..607695c 100644 --- a/flashforge-dashboard/frontend/public/index.html +++ b/flashforge-dashboard/frontend/public/index.html @@ -7,7 +7,7 @@ - +
From 15e466d659d2235736e4eb18e87edfed87f14ab7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 05:55:20 +0000 Subject: [PATCH 50/70] fix: remove stray frontend catch block --- flashforge-dashboard/frontend/public/app.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/flashforge-dashboard/frontend/public/app.js b/flashforge-dashboard/frontend/public/app.js index 541d660..fb0e92e 100644 --- a/flashforge-dashboard/frontend/public/app.js +++ b/flashforge-dashboard/frontend/public/app.js @@ -222,8 +222,6 @@ btnCameraOff.addEventListener('click', async () => { } catch (_) { } setTimeout(() => { btnCameraOff.disabled = false; }, 1000); }); - } catch (_) { /* ignore */ } -}); /* ── Print controls ──────────────────────────────────────────────────────── */ async function sendControl(action) { From 47106e82ea44735d45a1ce554b9264c65108674e Mon Sep 17 00:00:00 2001 From: MikManenti <152449131+MikManenti@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:41:50 +0200 Subject: [PATCH 51/70] Update WebSocket URL with mode parameter Added 'mode=mse' parameter to WebSocket URL for stream. --- flashforge-dashboard/frontend/public/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flashforge-dashboard/frontend/public/app.js b/flashforge-dashboard/frontend/public/app.js index fb0e92e..92e04a3 100644 --- a/flashforge-dashboard/frontend/public/app.js +++ b/flashforge-dashboard/frontend/public/app.js @@ -187,7 +187,7 @@ function initCamera() { const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; // Usiamo il percorso compatibile con il proxy definito in server.js - const wsUrl = `${wsProto}//${window.location.host}${BASE}/api/go2rtc/ws?src=${encodeURIComponent(streamName)}`; + const wsUrl = `${wsProto}//${window.location.host}${BASE}/api/go2rtc/ws?src=${encodeURIComponent(streamName)}&mode=mse`; console.log(`[Camera] Requesting stream at: ${wsUrl}`); cameraRtc.setAttribute('src', wsUrl); From 4ae1a29427acd0cfb3f5e5348c0066c6e92dcf9d Mon Sep 17 00:00:00 2001 From: MikManenti <152449131+MikManenti@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:09:46 +0200 Subject: [PATCH 52/70] Add files via upload --- flashforge-dashboard/server.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 0bfd336..a58797b 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -692,7 +692,9 @@ server.on('upgrade', (req, socket, head) => { } const streamName = urlObj.searchParams.get('src') || GO2RTC_STREAM; - const targetPath = `/api/ws?src=${encodeURIComponent(streamName)}`; + urlObj.searchParams.set('src', streamName); + // Forward all query parameters (including mode=mse) to go2rtc + const targetPath = `/api/ws${urlObj.search}`; const wsKey = req.headers['sec-websocket-key']; if (!wsKey) { socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); From bc71f08241d746541ca64ea9c7391e764bc2ee67 Mon Sep 17 00:00:00 2001 From: MikManenti <152449131+MikManenti@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:24:48 +0200 Subject: [PATCH 53/70] Add files via upload --- flashforge-dashboard/server.js | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index a58797b..ad42a76 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -580,6 +580,33 @@ const GO2RTC_CLIENT_CANDIDATE_PATHS = ['/api/go2rtc/client.js', '/video-rtc.js'] * GET /api/go2rtc/client.js * Proxies the go2rtc client JS so the browser can load it from the same origin. */ + +/** + * GET /api/go2rtc/mjpeg + * Bulletproof HTTP proxy for MJPEG stream. Bypasses all WebSocket/WebRTC ingress issues. + */ +app.get('/api/go2rtc/mjpeg', async (req, res) => { + const streamName = req.query.src || GO2RTC_STREAM; + if (!GO2RTC_URL) return res.status(503).send('go2rtc_url not configured'); + + const targetUrl = `${GO2RTC_URL}/api/stream.mjpeg?src=${encodeURIComponent(streamName)}`; + try { + const upstream = await fetch(targetUrl, { + headers: { Host: GO2RTC_UPSTREAM_HOST_HEADER }, + }); + if (!upstream.ok) return res.status(upstream.status).send('Upstream stream not found'); + + res.setHeader('Content-Type', upstream.headers.get('content-type') || 'image/jpeg'); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + + upstream.body.pipe(res); + } catch (err) { + res.status(502).send(err.message); + } +}); + app.get('/api/go2rtc/client.js', async (req, res) => { if (!GO2RTC_URL) { return res.status(503).send('// go2rtc_url not configured\n'); @@ -692,9 +719,7 @@ server.on('upgrade', (req, socket, head) => { } const streamName = urlObj.searchParams.get('src') || GO2RTC_STREAM; - urlObj.searchParams.set('src', streamName); - // Forward all query parameters (including mode=mse) to go2rtc - const targetPath = `/api/ws${urlObj.search}`; + const targetPath = `/api/ws?src=${encodeURIComponent(streamName)}`; const wsKey = req.headers['sec-websocket-key']; if (!wsKey) { socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); From 749851e561fd7b3b8c7f8b270353780b26f62ef8 Mon Sep 17 00:00:00 2001 From: MikManenti <152449131+MikManenti@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:25:16 +0200 Subject: [PATCH 54/70] Add files via upload --- flashforge-dashboard/frontend/public/app.js | 44 ++++++++++----------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/flashforge-dashboard/frontend/public/app.js b/flashforge-dashboard/frontend/public/app.js index 92e04a3..fdfd981 100644 --- a/flashforge-dashboard/frontend/public/app.js +++ b/flashforge-dashboard/frontend/public/app.js @@ -182,45 +182,45 @@ function updateUI(d) { function initCamera() { const streamName = window.GO2RTC_STREAM || 'Stampante'; cameraPlaceholder.classList.add('hidden'); - cameraImg.classList.remove('active'); - cameraRtc.classList.add('active'); - - const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - // Usiamo il percorso compatibile con il proxy definito in server.js - const wsUrl = `${wsProto}//${window.location.host}${BASE}/api/go2rtc/ws?src=${encodeURIComponent(streamName)}&mode=mse`; - console.log(`[Camera] Requesting stream at: ${wsUrl}`); - cameraRtc.setAttribute('src', wsUrl); + // Disabilita il player WebRTC + if (cameraRtc) { + cameraRtc.classList.remove('active'); + cameraRtc.removeAttribute('src'); + } + + // Usa l'immagine con flusso MJPEG tramite il proxy HTTP (infallibile sotto Ingress) + cameraImg.classList.add('active'); + const mjpegUrl = `${BASE}/api/go2rtc/mjpeg?src=${encodeURIComponent(streamName)}&t=${Date.now()}`; + console.log(`[Camera] Requesting MJPEG stream at: ${mjpegUrl}`); + cameraImg.src = mjpegUrl; } function disableCamera() { - cameraRtc.removeAttribute('src'); - cameraRtc.classList.remove('active'); - cameraImg.src = ''; - cameraImg.classList.remove('active'); + if (cameraRtc) { + cameraRtc.removeAttribute('src'); + cameraRtc.classList.remove('active'); + } + if (cameraImg) { + cameraImg.src = ''; + cameraImg.classList.remove('active'); + } cameraPlaceholder.classList.remove('hidden'); } btnCameraOn.addEventListener('click', async () => { - btnCameraOn.disabled = true; - console.log('[Camera] Waking up camera via API...'); + if (!GO2RTC_STREAM) return; try { await fetch(`${BASE}/api/camera`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'open' }) }); - } catch (err) { - console.warn('[Camera] Wake API error, trying stream anyway', err); - } - + } catch (_) { /* ignore – try to show stream anyway */ } initCamera(); - setTimeout(() => { btnCameraOn.disabled = false; }, 1000); }); btnCameraOff.addEventListener('click', async () => { - btnCameraOff.disabled = true; disableCamera(); try { await fetch(`${BASE}/api/camera`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'close' }) }); - } catch (_) { } - setTimeout(() => { btnCameraOff.disabled = false; }, 1000); + } catch (_) { /* ignore */ } }); /* ── Print controls ──────────────────────────────────────────────────────── */ From 96eb7763fc167b1ba57443a350bc5b72fa5d177f Mon Sep 17 00:00:00 2001 From: MikManenti <152449131+MikManenti@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:30:46 +0200 Subject: [PATCH 55/70] Add files via upload --- flashforge-dashboard/server.js | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index ad42a76..f7677fd 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -580,24 +580,18 @@ const GO2RTC_CLIENT_CANDIDATE_PATHS = ['/api/go2rtc/client.js', '/video-rtc.js'] * GET /api/go2rtc/client.js * Proxies the go2rtc client JS so the browser can load it from the same origin. */ - -/** - * GET /api/go2rtc/mjpeg - * Bulletproof HTTP proxy for MJPEG stream. Bypasses all WebSocket/WebRTC ingress issues. - */ app.get('/api/go2rtc/mjpeg', async (req, res) => { const streamName = req.query.src || GO2RTC_STREAM; if (!GO2RTC_URL) return res.status(503).send('go2rtc_url not configured'); const targetUrl = `${GO2RTC_URL}/api/stream.mjpeg?src=${encodeURIComponent(streamName)}`; try { - const upstream = await fetch(targetUrl, { - headers: { Host: GO2RTC_UPSTREAM_HOST_HEADER }, - }); + const upstream = await fetch(targetUrl); // Removed custom Host header, let node-fetch handle it if (!upstream.ok) return res.status(upstream.status).send('Upstream stream not found'); - res.setHeader('Content-Type', upstream.headers.get('content-type') || 'image/jpeg'); + res.setHeader('Content-Type', upstream.headers.get('content-type') || 'multipart/x-mixed-replace; boundary=--myboundary'); res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('Connection', 'close'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); From 7f430fee472d57be1fb86eb92d1b964a483006ab Mon Sep 17 00:00:00 2001 From: MikManenti <152449131+MikManenti@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:31:08 +0200 Subject: [PATCH 56/70] Add files via upload --- flashforge-dashboard/frontend/public/app.js | 20 +++++-------------- .../frontend/public/index.html | 16 +-------------- 2 files changed, 6 insertions(+), 30 deletions(-) diff --git a/flashforge-dashboard/frontend/public/app.js b/flashforge-dashboard/frontend/public/app.js index fdfd981..0b193af 100644 --- a/flashforge-dashboard/frontend/public/app.js +++ b/flashforge-dashboard/frontend/public/app.js @@ -21,7 +21,6 @@ let pollingTimer = null; /* ── DOM refs ────────────────────────────────────────────────────────────── */ const badge = document.getElementById('status-badge'); -const cameraRtc = document.getElementById('camera-rtc'); const cameraImg = document.getElementById('camera-img'); const cameraPlaceholder = document.getElementById('camera-placeholder'); const btnCameraOn = document.getElementById('btn-camera-on'); @@ -183,24 +182,15 @@ function initCamera() { const streamName = window.GO2RTC_STREAM || 'Stampante'; cameraPlaceholder.classList.add('hidden'); - // Disabilita il player WebRTC - if (cameraRtc) { - cameraRtc.classList.remove('active'); - cameraRtc.removeAttribute('src'); + if (cameraImg) { + cameraImg.classList.add('active'); + const mjpegUrl = `${BASE}/api/go2rtc/mjpeg?src=${encodeURIComponent(streamName)}&t=${Date.now()}`; + console.log(`[Camera] Requesting MJPEG stream at: ${mjpegUrl}`); + cameraImg.src = mjpegUrl; } - - // Usa l'immagine con flusso MJPEG tramite il proxy HTTP (infallibile sotto Ingress) - cameraImg.classList.add('active'); - const mjpegUrl = `${BASE}/api/go2rtc/mjpeg?src=${encodeURIComponent(streamName)}&t=${Date.now()}`; - console.log(`[Camera] Requesting MJPEG stream at: ${mjpegUrl}`); - cameraImg.src = mjpegUrl; } function disableCamera() { - if (cameraRtc) { - cameraRtc.removeAttribute('src'); - cameraRtc.classList.remove('active'); - } if (cameraImg) { cameraImg.src = ''; cameraImg.classList.remove('active'); diff --git a/flashforge-dashboard/frontend/public/index.html b/flashforge-dashboard/frontend/public/index.html index 607695c..2fb6861 100644 --- a/flashforge-dashboard/frontend/public/index.html +++ b/flashforge-dashboard/frontend/public/index.html @@ -7,7 +7,6 @@ -
@@ -16,14 +15,11 @@

🖨 FlashForge Dashboard

-
-

Camera

- Camera stream
Camera non disponibile @@ -33,17 +29,13 @@

Camera

-
-

Stato stampa

-
-
File @@ -83,7 +75,6 @@

Stato stampa

-

File in memoria

@@ -93,7 +84,6 @@

File in memoria

Premi "Aggiorna lista" per caricare i file.

-
-

Carica GCode

@@ -136,14 +125,11 @@

Carica GCode

- -
Non ancora aggiornato dati aggiornati ogni 4s • camera manuale
- - + \ No newline at end of file From 7ea47b3505633ff08cde409a916e79f6413e4966 Mon Sep 17 00:00:00 2001 From: MikManenti <152449131+MikManenti@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:46:56 +0200 Subject: [PATCH 57/70] Simplify camera control event listeners Removed fetch calls for camera control actions. --- flashforge-dashboard/frontend/public/app.js | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/flashforge-dashboard/frontend/public/app.js b/flashforge-dashboard/frontend/public/app.js index 0b193af..79484f2 100644 --- a/flashforge-dashboard/frontend/public/app.js +++ b/flashforge-dashboard/frontend/public/app.js @@ -200,18 +200,12 @@ function disableCamera() { btnCameraOn.addEventListener('click', async () => { if (!GO2RTC_STREAM) return; - try { - await fetch(`${BASE}/api/camera`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'open' }) }); - } catch (_) { /* ignore – try to show stream anyway */ } initCamera(); }); btnCameraOff.addEventListener('click', async () => { disableCamera(); - try { - await fetch(`${BASE}/api/camera`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'close' }) }); - } catch (_) { /* ignore */ } -}); +} /* ── Print controls ──────────────────────────────────────────────────────── */ async function sendControl(action) { From 84a40c6ef484a0cf466d650efd008fc6d86ceb8c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:52:34 +0000 Subject: [PATCH 58/70] fix: restore dashboard script execution --- flashforge-dashboard/frontend/public/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flashforge-dashboard/frontend/public/app.js b/flashforge-dashboard/frontend/public/app.js index 79484f2..60034de 100644 --- a/flashforge-dashboard/frontend/public/app.js +++ b/flashforge-dashboard/frontend/public/app.js @@ -205,7 +205,7 @@ btnCameraOn.addEventListener('click', async () => { btnCameraOff.addEventListener('click', async () => { disableCamera(); -} +}); /* ── Print controls ──────────────────────────────────────────────────────── */ async function sendControl(action) { From 627257ed8e5f5ed2c27496256a497df7c6613049 Mon Sep 17 00:00:00 2001 From: MikManenti <152449131+MikManenti@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:47:08 +0200 Subject: [PATCH 59/70] Add files via upload From 2efdeaba498818338c358d4a23bd426a1a95e0d6 Mon Sep 17 00:00:00 2001 From: MikManenti <152449131+MikManenti@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:47:39 +0200 Subject: [PATCH 60/70] Add files via upload --- flashforge-dashboard/config.yaml | 6 +- flashforge-dashboard/run.sh | 3 +- flashforge-dashboard/server.js | 140 ++++++++++++++++++++++++------- 3 files changed, 117 insertions(+), 32 deletions(-) diff --git a/flashforge-dashboard/config.yaml b/flashforge-dashboard/config.yaml index e9f6044..21832d3 100644 --- a/flashforge-dashboard/config.yaml +++ b/flashforge-dashboard/config.yaml @@ -36,8 +36,9 @@ options: mqtt_username: "" mqtt_password: "" mqtt_base_topic: "flashforge" - go2rtc_url: "http://a89bd424_go2rtc:1984" # URL interno dell'add-on go2rtc/Frigate - go2rtc_stream: "Stampante" # Nome dello stream in go2rtc + go2rtc_url: "http://ccab4aaf-frigate:1984" # URL base di go2rtc (porta 1984 anche se embedded in Frigate) + go2rtc_stream: "Stampante" # Nome dello stream configurato in Frigate/go2rtc + go2rtc_api_prefix: "/api/go2rtc" # Prefisso API go2rtc: "/api/go2rtc" se embedded in Frigate, "" se go2rtc standalone schema: printer_ip: str @@ -51,5 +52,6 @@ schema: mqtt_base_topic: str go2rtc_url: str? go2rtc_stream: str? + go2rtc_api_prefix: str? url: "https://github.com/MikManenti/flashforge-api-docs" diff --git a/flashforge-dashboard/run.sh b/flashforge-dashboard/run.sh index 7772aeb..3cfd647 100644 --- a/flashforge-dashboard/run.sh +++ b/flashforge-dashboard/run.sh @@ -20,6 +20,7 @@ export MQTT_PASSWORD="$(bashio::config 'mqtt_password')" export MQTT_BASE_TOPIC="$(bashio::config 'mqtt_base_topic')" export GO2RTC_URL="$(bashio::config 'go2rtc_url')" export GO2RTC_STREAM="$(bashio::config 'go2rtc_stream')" +export GO2RTC_API_PREFIX="$(bashio::config 'go2rtc_api_prefix')" if bashio::config.is_empty 'printer_ip'; then bashio::log.warning "printer_ip is not configured. Set it in the add-on Configuration tab." @@ -36,7 +37,7 @@ bashio::log.info "Listening on port ${PORT}" bashio::log.info "MQTT enabled: ${MQTT_ENABLED}" bashio::log.info "MQTT broker: ${MQTT_HOST}:${MQTT_PORT}" if [ -n "${GO2RTC_STREAM}" ]; then - bashio::log.info "go2rtc URL: ${GO2RTC_URL} / stream: ${GO2RTC_STREAM}" + bashio::log.info "go2rtc URL: ${GO2RTC_URL} / stream: ${GO2RTC_STREAM} / prefix: ${GO2RTC_API_PREFIX}" fi exec node /app/server.js diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index f7677fd..9906a42 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -571,34 +571,109 @@ let cachedVideoRtcJs = null; let cachedVideoRtcJsAt = 0; const VIDEO_RTC_CACHE_TTL_MS = 3600000; // 1 hour -const GO2RTC_UPSTREAM_HOST = 'ccab4aaf-frigate'; -const GO2RTC_UPSTREAM_PORT = 1984; -const GO2RTC_UPSTREAM_HOST_HEADER = `${GO2RTC_UPSTREAM_HOST}:${GO2RTC_UPSTREAM_PORT}`; -const GO2RTC_CLIENT_CANDIDATE_PATHS = ['/api/go2rtc/client.js', '/video-rtc.js']; +/** + * go2rtc embedded in Frigate exposes its APIs under /api/go2rtc/: + * + * MJPEG stream → GET /api/go2rtc/api/stream.mjpeg?src= + * WebSocket → WS /api/go2rtc/api/ws?src= + * client.js → GET /api/go2rtc/api/go2rtc/client.js + * or GET /api/go2rtc/video-rtc.js (older Frigate) + * + * GO2RTC_URL should therefore be set to the Frigate base URL, e.g.: + * http://ccab4aaf-frigate:5000 + * The /api/go2rtc prefix is appended here automatically. + * + * If you ever switch to a standalone go2rtc instance (no Frigate), set + * go2rtc_url: "http://:1984" + * and the GO2RTC_API_PREFIX env var to "" (empty string) so no prefix is added. + */ +const GO2RTC_API_PREFIX = (process.env.GO2RTC_API_PREFIX !== undefined) + ? process.env.GO2RTC_API_PREFIX // explicit override (e.g. '' for standalone) + : '/api/go2rtc'; // default: Frigate embedded + +// Candidate paths for the go2rtc browser client script (tried in order). +const GO2RTC_CLIENT_CANDIDATE_PATHS = [ + `${GO2RTC_API_PREFIX}/api/go2rtc/client.js`, // Frigate embedded (recent) + `${GO2RTC_API_PREFIX}/video-rtc.js`, // Frigate embedded (older) + '/api/go2rtc/client.js', // go2rtc standalone + '/video-rtc.js', // go2rtc standalone (older) +]; /** - * GET /api/go2rtc/client.js - * Proxies the go2rtc client JS so the browser can load it from the same origin. + * Parses GO2RTC_URL into host + port + basePath components so we can use + * Node's http.request() instead of fetch(). fetch() buffers the response + * body and is therefore unusable for an infinite MJPEG multipart stream. */ -app.get('/api/go2rtc/mjpeg', async (req, res) => { - const streamName = req.query.src || GO2RTC_STREAM; - if (!GO2RTC_URL) return res.status(503).send('go2rtc_url not configured'); - - const targetUrl = `${GO2RTC_URL}/api/stream.mjpeg?src=${encodeURIComponent(streamName)}`; +function parseGo2rtcUrl() { try { - const upstream = await fetch(targetUrl); // Removed custom Host header, let node-fetch handle it - if (!upstream.ok) return res.status(upstream.status).send('Upstream stream not found'); - - res.setHeader('Content-Type', upstream.headers.get('content-type') || 'multipart/x-mixed-replace; boundary=--myboundary'); - res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); - res.setHeader('Connection', 'close'); - res.setHeader('Pragma', 'no-cache'); - res.setHeader('Expires', '0'); - - upstream.body.pipe(res); - } catch (err) { - res.status(502).send(err.message); + const u = new URL(GO2RTC_URL); + return { + host: u.hostname, + port: Number(u.port) || (u.protocol === 'https:' ? 443 : 80), + // Strip trailing slash; path segments are appended explicitly below. + basePath: u.pathname.replace(/\/$/, ''), + }; + } catch (_) { + return { host: 'ccab4aaf-frigate', port: 1984, basePath: '' }; } +} + +/** + * GET /api/go2rtc/mjpeg?src= + * + * Proxy the MJPEG multipart stream from Frigate/go2rtc to the browser. + * We use http.request() with an immediate pipe so every JPEG frame is + * forwarded as soon as it arrives — fetch() would buffer the whole body + * and never flush it, breaking live video. + */ +app.get('/api/go2rtc/mjpeg', (req, res) => { + const streamName = req.query.src || GO2RTC_STREAM; + if (!GO2RTC_URL) return res.status(503).send('go2rtc_url not configured'); + + const { host, port, basePath } = parseGo2rtcUrl(); + // e.g. /api/go2rtc/api/stream.mjpeg?src=Stampante (Frigate embedded) + const upstreamPath = `${basePath}${GO2RTC_API_PREFIX}/api/stream.mjpeg?src=${encodeURIComponent(streamName)}`; + + console.log(`[go2rtc] MJPEG upstream: http://${host}:${port}${upstreamPath}`); + + const proxyReq = http.request( + { host, port, path: upstreamPath, method: 'GET' }, + (proxyRes) => { + if (proxyRes.statusCode !== 200) { + res.status(proxyRes.statusCode || 502).send('Upstream stream not found'); + proxyRes.resume(); // drain so the socket is released + return; + } + + // Forward Content-Type exactly as go2rtc sends it (includes boundary). + res.setHeader( + 'Content-Type', + proxyRes.headers['content-type'] || 'multipart/x-mixed-replace; boundary=ffboundary', + ); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); // send headers immediately, before any body data + + // Pipe every chunk straight to the browser without buffering. + proxyRes.pipe(res, { end: true }); + + // If the browser disconnects, tear down the upstream request too. + req.on('close', () => proxyReq.destroy()); + }, + ); + + proxyReq.on('error', (err) => { + console.warn(`[go2rtc] MJPEG proxy error: ${err.message}`); + if (!res.headersSent) { + res.status(502).send(err.message); + } else { + res.end(); + } + }); + + proxyReq.end(); }); app.get('/api/go2rtc/client.js', async (req, res) => { @@ -615,9 +690,9 @@ app.get('/api/go2rtc/client.js', async (req, res) => { try { for (const candidatePath of GO2RTC_CLIENT_CANDIDATE_PATHS) { - const upstream = await fetch(`${GO2RTC_URL}${candidatePath}`, { - headers: { Host: GO2RTC_UPSTREAM_HOST_HEADER }, - }); + const url = `${GO2RTC_URL}${candidatePath}`; + console.log(`[go2rtc] Trying client.js at: ${url}`); + const upstream = await fetch(url); if (!upstream.ok) continue; cachedVideoRtcJs = await upstream.text(); @@ -713,7 +788,6 @@ server.on('upgrade', (req, socket, head) => { } const streamName = urlObj.searchParams.get('src') || GO2RTC_STREAM; - const targetPath = `/api/ws?src=${encodeURIComponent(streamName)}`; const wsKey = req.headers['sec-websocket-key']; if (!wsKey) { socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); @@ -721,7 +795,15 @@ server.on('upgrade', (req, socket, head) => { return; } - const proxySocket = net.connect(GO2RTC_UPSTREAM_PORT, GO2RTC_UPSTREAM_HOST, () => { + // Resolve host/port from GO2RTC_URL at runtime so we honour whatever the + // user configured (go2rtc standalone, Frigate built-in, custom port…). + const { host: wsHost, port: wsPort, basePath: wsBase } = parseGo2rtcUrl(); + // e.g. /api/go2rtc/api/ws?src=Stampante (Frigate embedded) + const targetPath = `${wsBase}${GO2RTC_API_PREFIX}/api/ws?src=${encodeURIComponent(streamName)}`; + const wsHostHeader = `${wsHost}:${wsPort}`; + console.log(`[go2rtc] WS upstream: ws://${wsHost}:${wsPort}${targetPath}`); + + const proxySocket = net.connect(wsPort, wsHost, () => { const wsVersion = req.headers['sec-websocket-version'] || '13'; const wsProtocol = req.headers['sec-websocket-protocol']; const wsExtensions = req.headers['sec-websocket-extensions']; @@ -730,7 +812,7 @@ server.on('upgrade', (req, socket, head) => { const lines = [ `GET ${targetPath} HTTP/1.1`, - `Host: ${GO2RTC_UPSTREAM_HOST_HEADER}`, + `Host: ${wsHostHeader}`, 'Upgrade: websocket', 'Connection: Upgrade', `Sec-WebSocket-Key: ${wsKey}`, From 1994b802c8ec0d85816faa676be24d58397d1cdc Mon Sep 17 00:00:00 2001 From: MikManenti <152449131+MikManenti@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:48:45 +0200 Subject: [PATCH 61/70] Add files via upload From fcbea41de3f866c180f101389d3868ab88183626 Mon Sep 17 00:00:00 2001 From: MikManenti <152449131+MikManenti@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:09:29 +0200 Subject: [PATCH 62/70] Add files via upload --- flashforge-dashboard/config.yaml | 2 -- flashforge-dashboard/run.sh | 4 +-- flashforge-dashboard/server.js | 59 +++++++------------------------- 3 files changed, 15 insertions(+), 50 deletions(-) diff --git a/flashforge-dashboard/config.yaml b/flashforge-dashboard/config.yaml index 21832d3..dea4264 100644 --- a/flashforge-dashboard/config.yaml +++ b/flashforge-dashboard/config.yaml @@ -38,7 +38,6 @@ options: mqtt_base_topic: "flashforge" go2rtc_url: "http://ccab4aaf-frigate:1984" # URL base di go2rtc (porta 1984 anche se embedded in Frigate) go2rtc_stream: "Stampante" # Nome dello stream configurato in Frigate/go2rtc - go2rtc_api_prefix: "/api/go2rtc" # Prefisso API go2rtc: "/api/go2rtc" se embedded in Frigate, "" se go2rtc standalone schema: printer_ip: str @@ -52,6 +51,5 @@ schema: mqtt_base_topic: str go2rtc_url: str? go2rtc_stream: str? - go2rtc_api_prefix: str? url: "https://github.com/MikManenti/flashforge-api-docs" diff --git a/flashforge-dashboard/run.sh b/flashforge-dashboard/run.sh index 3cfd647..d358a4c 100644 --- a/flashforge-dashboard/run.sh +++ b/flashforge-dashboard/run.sh @@ -20,7 +20,7 @@ export MQTT_PASSWORD="$(bashio::config 'mqtt_password')" export MQTT_BASE_TOPIC="$(bashio::config 'mqtt_base_topic')" export GO2RTC_URL="$(bashio::config 'go2rtc_url')" export GO2RTC_STREAM="$(bashio::config 'go2rtc_stream')" -export GO2RTC_API_PREFIX="$(bashio::config 'go2rtc_api_prefix')" + if bashio::config.is_empty 'printer_ip'; then bashio::log.warning "printer_ip is not configured. Set it in the add-on Configuration tab." @@ -37,7 +37,7 @@ bashio::log.info "Listening on port ${PORT}" bashio::log.info "MQTT enabled: ${MQTT_ENABLED}" bashio::log.info "MQTT broker: ${MQTT_HOST}:${MQTT_PORT}" if [ -n "${GO2RTC_STREAM}" ]; then - bashio::log.info "go2rtc URL: ${GO2RTC_URL} / stream: ${GO2RTC_STREAM} / prefix: ${GO2RTC_API_PREFIX}" + bashio::log.info "go2rtc URL: ${GO2RTC_URL} / stream: ${GO2RTC_STREAM} " fi exec node /app/server.js diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 9906a42..30d6c54 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -571,69 +571,36 @@ let cachedVideoRtcJs = null; let cachedVideoRtcJsAt = 0; const VIDEO_RTC_CACHE_TTL_MS = 3600000; // 1 hour -/** - * go2rtc embedded in Frigate exposes its APIs under /api/go2rtc/: - * - * MJPEG stream → GET /api/go2rtc/api/stream.mjpeg?src= - * WebSocket → WS /api/go2rtc/api/ws?src= - * client.js → GET /api/go2rtc/api/go2rtc/client.js - * or GET /api/go2rtc/video-rtc.js (older Frigate) - * - * GO2RTC_URL should therefore be set to the Frigate base URL, e.g.: - * http://ccab4aaf-frigate:5000 - * The /api/go2rtc prefix is appended here automatically. - * - * If you ever switch to a standalone go2rtc instance (no Frigate), set - * go2rtc_url: "http://:1984" - * and the GO2RTC_API_PREFIX env var to "" (empty string) so no prefix is added. - */ -const GO2RTC_API_PREFIX = (process.env.GO2RTC_API_PREFIX !== undefined) - ? process.env.GO2RTC_API_PREFIX // explicit override (e.g. '' for standalone) - : '/api/go2rtc'; // default: Frigate embedded - -// Candidate paths for the go2rtc browser client script (tried in order). +// go2rtc client script candidate paths (tried in order). const GO2RTC_CLIENT_CANDIDATE_PATHS = [ - `${GO2RTC_API_PREFIX}/api/go2rtc/client.js`, // Frigate embedded (recent) - `${GO2RTC_API_PREFIX}/video-rtc.js`, // Frigate embedded (older) - '/api/go2rtc/client.js', // go2rtc standalone - '/video-rtc.js', // go2rtc standalone (older) + '/api/go2rtc/client.js', // go2rtc >= 1.9 + '/video-rtc.js', // go2rtc older versions ]; /** - * Parses GO2RTC_URL into host + port + basePath components so we can use - * Node's http.request() instead of fetch(). fetch() buffers the response - * body and is therefore unusable for an infinite MJPEG multipart stream. + * Parses GO2RTC_URL into { host, port } for use with http.request(). + * GO2RTC_URL must point directly to go2rtc port 1984, e.g.: + * http://ccab4aaf-frigate:1984 */ function parseGo2rtcUrl() { try { const u = new URL(GO2RTC_URL); - return { - host: u.hostname, - port: Number(u.port) || (u.protocol === 'https:' ? 443 : 80), - // Strip trailing slash; path segments are appended explicitly below. - basePath: u.pathname.replace(/\/$/, ''), - }; + return { host: u.hostname, port: Number(u.port) || 1984 }; } catch (_) { - return { host: 'ccab4aaf-frigate', port: 1984, basePath: '' }; + return { host: 'ccab4aaf-frigate', port: 1984 }; } } /** * GET /api/go2rtc/mjpeg?src= - * - * Proxy the MJPEG multipart stream from Frigate/go2rtc to the browser. - * We use http.request() with an immediate pipe so every JPEG frame is - * forwarded as soon as it arrives — fetch() would buffer the whole body - * and never flush it, breaking live video. + * Proxy the MJPEG stream from go2rtc to the browser via http.request() + pipe. */ app.get('/api/go2rtc/mjpeg', (req, res) => { const streamName = req.query.src || GO2RTC_STREAM; if (!GO2RTC_URL) return res.status(503).send('go2rtc_url not configured'); - const { host, port, basePath } = parseGo2rtcUrl(); - // e.g. /api/go2rtc/api/stream.mjpeg?src=Stampante (Frigate embedded) - const upstreamPath = `${basePath}${GO2RTC_API_PREFIX}/api/stream.mjpeg?src=${encodeURIComponent(streamName)}`; - + const { host, port } = parseGo2rtcUrl(); + const upstreamPath = `/api/stream.mjpeg?src=${encodeURIComponent(streamName)}`; console.log(`[go2rtc] MJPEG upstream: http://${host}:${port}${upstreamPath}`); const proxyReq = http.request( @@ -797,9 +764,9 @@ server.on('upgrade', (req, socket, head) => { // Resolve host/port from GO2RTC_URL at runtime so we honour whatever the // user configured (go2rtc standalone, Frigate built-in, custom port…). - const { host: wsHost, port: wsPort, basePath: wsBase } = parseGo2rtcUrl(); + const { host: wsHost, port: wsPort } = parseGo2rtcUrl(); // e.g. /api/go2rtc/api/ws?src=Stampante (Frigate embedded) - const targetPath = `${wsBase}${GO2RTC_API_PREFIX}/api/ws?src=${encodeURIComponent(streamName)}`; + const targetPath = `/api/ws?src=${encodeURIComponent(streamName)}`; const wsHostHeader = `${wsHost}:${wsPort}`; console.log(`[go2rtc] WS upstream: ws://${wsHost}:${wsPort}${targetPath}`); From 736e9b08f7d18646a520680862cfb2c0750617e0 Mon Sep 17 00:00:00 2001 From: MikManenti <152449131+MikManenti@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:09:56 +0200 Subject: [PATCH 63/70] Add files via upload From 19d979ef086866840d496b690f49dcb54cb8b601 Mon Sep 17 00:00:00 2001 From: MikManenti <152449131+MikManenti@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:17:32 +0200 Subject: [PATCH 64/70] Add files via upload --- flashforge-dashboard/server.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 30d6c54..bc66003 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -621,6 +621,8 @@ app.get('/api/go2rtc/mjpeg', (req, res) => { res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); res.setHeader('Connection', 'keep-alive'); + // Tell nginx (used by HA Ingress) not to buffer this streaming response. + res.setHeader('X-Accel-Buffering', 'no'); res.flushHeaders(); // send headers immediately, before any body data // Pipe every chunk straight to the browser without buffering. From 47d63a8959fe461764770c2a614ed8fb8e1fd126 Mon Sep 17 00:00:00 2001 From: MikManenti <152449131+MikManenti@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:18:00 +0200 Subject: [PATCH 65/70] Add files via upload From c4ee5a0a07933b55b01b3d79732eebc383dc388a Mon Sep 17 00:00:00 2001 From: MikManenti <152449131+MikManenti@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:18:45 +0200 Subject: [PATCH 66/70] Add files via upload From bfe3fcf9ce577e4c05e27bd20a41aa3705d8908d Mon Sep 17 00:00:00 2001 From: MikManenti <152449131+MikManenti@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:06:05 +0200 Subject: [PATCH 67/70] Add files via upload --- flashforge-dashboard/config.yaml | 14 +++- flashforge-dashboard/run.sh | 8 +- flashforge-dashboard/server.js | 125 ++++++++++++++++++++++++++----- 3 files changed, 123 insertions(+), 24 deletions(-) diff --git a/flashforge-dashboard/config.yaml b/flashforge-dashboard/config.yaml index dea4264..f9c6a37 100644 --- a/flashforge-dashboard/config.yaml +++ b/flashforge-dashboard/config.yaml @@ -14,16 +14,18 @@ arch: - armv7 - armhf -# Ingress: accessibile dalla sidebar di HA senza aprire porte extra +# Ingress: accessibile dalla sidebar di HA senza aprire porte extra. +# NOTA: ingress_port è ora 8100 (porta interna senza auth). +# La porta 8099 è esposta all'esterno e richiede autenticazione. ingress: true -ingress_port: 8099 +ingress_port: 8100 panel_icon: mdi:printer-3d panel_title: FlashForge webui: "http://[HOST]:[PORT:8099]" ports: 8099/tcp: 8099 ports_description: - 8099/tcp: Dashboard HTTP + 8099/tcp: Dashboard HTTP (accesso diretto, con auth se configurata) # Opzioni configurabili dal pannello HA (Add-on → Configurazione) options: @@ -38,6 +40,10 @@ options: mqtt_base_topic: "flashforge" go2rtc_url: "http://ccab4aaf-frigate:1984" # URL base di go2rtc (porta 1984 anche se embedded in Frigate) go2rtc_stream: "Stampante" # Nome dello stream configurato in Frigate/go2rtc + # Credenziali per l'accesso diretto sulla porta 8099. + # Se lasciate vuote, la porta 8099 non richiede autenticazione. + auth_username: "" + auth_password: "" schema: printer_ip: str @@ -51,5 +57,7 @@ schema: mqtt_base_topic: str go2rtc_url: str? go2rtc_stream: str? + auth_username: str? + auth_password: password? url: "https://github.com/MikManenti/flashforge-api-docs" diff --git a/flashforge-dashboard/run.sh b/flashforge-dashboard/run.sh index d358a4c..75483fc 100644 --- a/flashforge-dashboard/run.sh +++ b/flashforge-dashboard/run.sh @@ -9,9 +9,12 @@ bashio::log.info "Starting FlashForge Dashboard..." export PRINTER_IP="$(bashio::config 'printer_ip')" export SERIAL_NUMBER="$(bashio::config 'serial_number')" export CHECK_CODE="$(bashio::config 'check_code')" -export PORT="8099" +export DIRECT_PORT="8099" +export INGRESS_PORT="8100" export NODE_ENV="production" export INGRESS_PATH="$(bashio::addon.ingress_entry)" +export AUTH_USERNAME="$(bashio::config 'auth_username')" +export AUTH_PASSWORD="$(bashio::config 'auth_password')" export MQTT_ENABLED="$(bashio::config 'mqtt_enabled')" export MQTT_HOST="$(bashio::config 'mqtt_host')" export MQTT_PORT="$(bashio::config 'mqtt_port')" @@ -33,7 +36,8 @@ if bashio::config.is_empty 'check_code'; then fi bashio::log.info "Printer IP: ${PRINTER_IP}" -bashio::log.info "Listening on port ${PORT}" +bashio::log.info "Direct access port: ${DIRECT_PORT} (auth enabled: $([ -n "${AUTH_USERNAME}" ] && echo 'yes' || echo 'no'))" +bashio::log.info "Ingress port: ${INGRESS_PORT}" bashio::log.info "MQTT enabled: ${MQTT_ENABLED}" bashio::log.info "MQTT broker: ${MQTT_HOST}:${MQTT_PORT}" if [ -n "${GO2RTC_STREAM}" ]; then diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index bc66003..4f1788e 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -12,7 +12,8 @@ const http = require('http'); const net = require('net'); const app = express(); -const PORT = process.env.PORT || 8099; +const DIRECT_PORT = Number(process.env.DIRECT_PORT || 8099); +const INGRESS_PORT = Number(process.env.INGRESS_PORT || 8100); const PRINTER_IP = process.env.PRINTER_IP; const SERIAL_NUMBER = process.env.SERIAL_NUMBER; const CHECK_CODE = process.env.CHECK_CODE; @@ -36,6 +37,12 @@ const MQTT_PASSWORD = process.env.MQTT_PASSWORD || ''; const MQTT_BASE_TOPIC = sanitizeTopic(process.env.MQTT_BASE_TOPIC || 'flashforge'); const MQTT_POLL_INTERVAL_MS = 10000; +// Credenziali per la Basic Auth sulla porta diretta (8099). +// Se entrambe vuote, l'accesso diretto non richiede autenticazione. +const AUTH_USERNAME = (process.env.AUTH_USERNAME || '').trim(); +const AUTH_PASSWORD = (process.env.AUTH_PASSWORD || '').trim(); +const AUTH_ENABLED = !!(AUTH_USERNAME && AUTH_PASSWORD); + const DEVICE_ID = String(SERIAL_NUMBER || PRINTER_IP || 'flashforge_printer') .replace(/[^\w-]/g, '_') .toLowerCase(); @@ -399,6 +406,28 @@ function requireConfig(req, res, next) { next(); } +/** + * HTTP Basic Auth middleware – usato SOLO sul server ad accesso diretto (porta 8099). + * Confronto sicuro che regge password contenenti il carattere ':'. + */ +function basicAuth(req, res, next) { + if (!AUTH_ENABLED) return next(); + const authHeader = req.headers['authorization'] || ''; + if (!authHeader.startsWith('Basic ')) { + res.setHeader('WWW-Authenticate', 'Basic realm="FlashForge Dashboard"'); + return res.status(401).send('Unauthorized'); + } + const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8'); + const colonIdx = decoded.indexOf(':'); + const user = colonIdx === -1 ? decoded : decoded.slice(0, colonIdx); + const pass = colonIdx === -1 ? '' : decoded.slice(colonIdx + 1); + if (user !== AUTH_USERNAME || pass !== AUTH_PASSWORD) { + res.setHeader('WWW-Authenticate', 'Basic realm="FlashForge Dashboard"'); + return res.status(401).send('Unauthorized'); + } + next(); +} + // ── API Routes ─────────────────────────────────────────────────────────────── /** @@ -718,24 +747,55 @@ function serveIndex(req, res) { app.get('*', serveIndex); // ── Start ──────────────────────────────────────────────────────────────────── -const server = http.createServer(app); -server.listen(PORT, () => { - console.log(`FlashForge Dashboard (HA add-on) running on port ${PORT}`); - console.log(`Ingress path: ${INGRESS_PATH || '(none)'}`); - console.log(`Direct HTTP URL: http://:${PORT}`); - if (!PRINTER_IP || !SERIAL_NUMBER || !CHECK_CODE) { - console.warn('⚠ printer_ip, serial_number or check_code not set. Configure them in the HA add-on Configuration tab.'); - } - setupMqtt(); - if (MQTT_ENABLED) { - mqttPollingTimer = setInterval(() => { - refreshPrinterState(); - }, MQTT_POLL_INTERVAL_MS); +// ── Server 1: Ingress HA (nessuna auth – HA richiede già il login) ────────── +// HA Supervisor proxia qui il traffico dalla sidebar. La porta NON è esposta +// all'esterno (non è nella sezione `ports` di config.yaml). +const ingressServer = http.createServer(app); + +// ── Server 2: Accesso diretto (Basic Auth quando le credenziali sono impostate) +// Esposto sulla rete locale tramite la sezione `ports` di config.yaml. +const directServer = http.createServer((req, res) => { + if (AUTH_ENABLED) { + basicAuth(req, res, () => app(req, res)); + } else { + app(req, res); } }); -server.on('upgrade', (req, socket, head) => { +// ── WebSocket upgrade (proxy go2rtc) ───────────────────────────────────────── +/** + * Gestisce l'upgrade WebSocket per entrambi i server. + * @param {boolean} requireAuth true solo per il server ad accesso diretto + */ +function handleWsUpgrade(req, socket, head, requireAuth) { + // Verifica Basic Auth per il server diretto + if (requireAuth && AUTH_ENABLED) { + const authHeader = req.headers['authorization'] || ''; + if (!authHeader.startsWith('Basic ')) { + socket.write( + 'HTTP/1.1 401 Unauthorized\r\n' + + 'WWW-Authenticate: Basic realm="FlashForge Dashboard"\r\n' + + 'Content-Length: 0\r\nConnection: close\r\n\r\n', + ); + socket.destroy(); + return; + } + const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8'); + const colonIdx = decoded.indexOf(':'); + const user = colonIdx === -1 ? decoded : decoded.slice(0, colonIdx); + const pass = colonIdx === -1 ? '' : decoded.slice(colonIdx + 1); + if (user !== AUTH_USERNAME || pass !== AUTH_PASSWORD) { + socket.write( + 'HTTP/1.1 401 Unauthorized\r\n' + + 'WWW-Authenticate: Basic realm="FlashForge Dashboard"\r\n' + + 'Content-Length: 0\r\nConnection: close\r\n\r\n', + ); + socket.destroy(); + return; + } + } + let urlPath = req.url || '/'; if (INGRESS_PATH && urlPath.startsWith(INGRESS_PATH)) { urlPath = urlPath.substring(INGRESS_PATH.length) || '/'; @@ -764,10 +824,7 @@ server.on('upgrade', (req, socket, head) => { return; } - // Resolve host/port from GO2RTC_URL at runtime so we honour whatever the - // user configured (go2rtc standalone, Frigate built-in, custom port…). const { host: wsHost, port: wsPort } = parseGo2rtcUrl(); - // e.g. /api/go2rtc/api/ws?src=Stampante (Frigate embedded) const targetPath = `/api/ws?src=${encodeURIComponent(streamName)}`; const wsHostHeader = `${wsHost}:${wsPort}`; console.log(`[go2rtc] WS upstream: ws://${wsHost}:${wsPort}${targetPath}`); @@ -813,6 +870,33 @@ server.on('upgrade', (req, socket, head) => { socket.pipe(proxySocket); proxySocket.pipe(socket); +} + +// Collega l'upgrade handler a entrambi i server +ingressServer.on('upgrade', (req, socket, head) => handleWsUpgrade(req, socket, head, false)); +directServer.on('upgrade', (req, socket, head) => handleWsUpgrade(req, socket, head, true)); + +// ── Listen ─────────────────────────────────────────────────────────────────── + +// Server Ingress (porta interna, nessuna auth) +ingressServer.listen(INGRESS_PORT, () => { + console.log(`FlashForge Dashboard – Ingress HA in ascolto sulla porta ${INGRESS_PORT} (nessuna auth)`); + console.log(`Ingress path: ${INGRESS_PATH || '(none)'}`); +}); + +// Server diretto (porta esposta, con auth se configurata) +directServer.listen(DIRECT_PORT, () => { + console.log(`FlashForge Dashboard – Accesso diretto sulla porta ${DIRECT_PORT} (auth ${AUTH_ENABLED ? 'ATTIVA' : 'DISATTIVA'})`); + console.log(`Direct HTTP URL: http://:${DIRECT_PORT}`); + if (!PRINTER_IP || !SERIAL_NUMBER || !CHECK_CODE) { + console.warn('⚠ printer_ip, serial_number o check_code non impostati. Configurali nel pannello Add-on → Configurazione.'); + } + setupMqtt(); + if (MQTT_ENABLED) { + mqttPollingTimer = setInterval(() => { + refreshPrinterState(); + }, MQTT_POLL_INTERVAL_MS); + } }); function shutdown() { @@ -828,7 +912,10 @@ function shutdown() { console.warn(`MQTT shutdown warning: ${err.message}`); } } - server.close(() => process.exit(0)); + let closed = 0; + const onClosed = () => { if (++closed >= 2) process.exit(0); }; + directServer.close(onClosed); + ingressServer.close(onClosed); setTimeout(() => process.exit(0), 3000).unref(); } From 3bb117b4fdfd0e1a584a49c4483d92e046049b7c Mon Sep 17 00:00:00 2001 From: MikManenti <152449131+MikManenti@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:06:40 +0200 Subject: [PATCH 68/70] Add files via upload From 4cbe63f9ce541aad8b86e3227b36fd57843c52ba Mon Sep 17 00:00:00 2001 From: MikManenti <152449131+MikManenti@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:07:12 +0200 Subject: [PATCH 69/70] Add files via upload From fd0d34b7dd1457cc356959058064047c96992067 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:33:43 +0000 Subject: [PATCH 70/70] fix: use plain Node.js res methods in basicAuth to fix TypeError on directServer --- flashforge-dashboard/server.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/flashforge-dashboard/server.js b/flashforge-dashboard/server.js index 4f1788e..3f6e280 100644 --- a/flashforge-dashboard/server.js +++ b/flashforge-dashboard/server.js @@ -414,16 +414,22 @@ function basicAuth(req, res, next) { if (!AUTH_ENABLED) return next(); const authHeader = req.headers['authorization'] || ''; if (!authHeader.startsWith('Basic ')) { - res.setHeader('WWW-Authenticate', 'Basic realm="FlashForge Dashboard"'); - return res.status(401).send('Unauthorized'); + res.writeHead(401, { + 'WWW-Authenticate': 'Basic realm="FlashForge Dashboard"', + 'Content-Type': 'text/plain', + }); + return res.end('Unauthorized'); } const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8'); const colonIdx = decoded.indexOf(':'); const user = colonIdx === -1 ? decoded : decoded.slice(0, colonIdx); const pass = colonIdx === -1 ? '' : decoded.slice(colonIdx + 1); if (user !== AUTH_USERNAME || pass !== AUTH_PASSWORD) { - res.setHeader('WWW-Authenticate', 'Basic realm="FlashForge Dashboard"'); - return res.status(401).send('Unauthorized'); + res.writeHead(401, { + 'WWW-Authenticate': 'Basic realm="FlashForge Dashboard"', + 'Content-Type': 'text/plain', + }); + return res.end('Unauthorized'); } next(); }