// client.js // UI: Buttons, Anzeige von Result als JSON + Baum, Fallback für Commands function appendLog(line) { const el = document.getElementById("log"); if (!el) return; const now = new Date().toISOString(); el.value += `[${now}] ${line}\n`; el.scrollTop = el.scrollHeight; } function clearTextarea(id) { const el = document.getElementById(id); if (el) el.value = ""; } function clearElement(id) { const el = document.getElementById(id); if (el) el.innerHTML = ""; } function formatScalar(value) { if (value === null) return "null"; if (value === undefined) return "undefined"; if (typeof value === "string") return JSON.stringify(value); if (typeof value === "number" && Number.isFinite(value)) return String(value); if (typeof value === "boolean") return String(value); return String(value); } function renderTree(container, value, key = "result", open = true) { if (!container) return; container.innerHTML = ""; container.appendChild(renderNode(key, value, open)); } function renderNode(key, value, open = false) { const isObject = value !== null && typeof value === "object"; if (!isObject) { const leaf = document.createElement("div"); leaf.className = "tree-leaf"; leaf.textContent = `${key}: ${formatScalar(value)}`; return leaf; } const details = document.createElement("details"); details.open = open; const summary = document.createElement("summary"); summary.textContent = Array.isArray(value) ? `${key} [${value.length}]` : key; details.appendChild(summary); const body = document.createElement("div"); body.style.marginLeft = "16px"; if (Array.isArray(value)) { value.forEach((item, idx) => { body.appendChild(renderNode(String(idx), item, false)); }); } else { Object.entries(value).forEach(([childKey, childVal]) => { body.appendChild(renderNode(childKey, childVal, false)); }); } details.appendChild(body); return details; } function renderResult(result) { const jsonEl = document.getElementById("result-json"); const treeEl = document.getElementById("result-tree"); if (jsonEl) { jsonEl.value = JSON.stringify(result, null, 2); } renderTree(treeEl, result, "result", true); } dataCache = null headersCache = null rowsCache = null timeCache = null jsonCache = null async function fetchCSV(){ if(dataCache == null || headersCache == null || rowsCache == null || timeCache == null){ ({ data: dataCache, headers: headersCache, rows: rowsCache } = await fetchCSV_fromServer()); timeCache = Date.now(); } if (Date.now() - timeCache > 1000){ ({ data: dataCache, headers: headersCache, rows: rowsCache } = await fetchCSV_fromServer()); timeCache = Date.now(); } return {data: dataCache, headers: headersCache, rows: rowsCache} } async function fetchCSV_fromServer() { const res = await fetch("/api/latest-snapshot"); if (!res.ok) throw new Error("Fehler beim Laden des Snapshots"); let data; if (res.headers.get("content-type")?.includes("application/json")) { data = await res.json(); } else { const csvData = await res.text(); data = { filename: "latest.csv", mtime: new Date().toISOString(), content: csvData }; } if (!jsonCache && data.jsonFile) { jsonCache = JSON.parse(data.jsonFile.content); } const lines = data.content.trim().split(/\r?\n/).filter(Boolean); if (lines.length < 2) { throw new Error("Keine oder unvollständige Daten"); } const headers = lines[0].split(",").map(h => h.trim()); const rows = lines.slice(1).map(line => { const cells = line.split(","); const obj = {}; headers.forEach((h, i) => { const raw = (cells[i] ?? "").trim(); const numeric = Number(raw); obj[h] = raw !== "" && Number.isFinite(numeric) ? numeric : raw; }); return obj; }); return { data, headers, rows }; } async function fetchWebcamSnapshotData() { const { data, headers, rows } = await fetchCSV(); return { filename: data.filename, mtime: data.mtime, headers, rows, robotIntrinsics: jsonCache, imageFile: data.imageFile, image2: data.image2 }; } async function sendToBodyTracker({imageFile, image2, robotIntrinsics}) { const response = await fetch('/api/estimate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ imageFile, image2, robotIntrinsics }) }); if (!response.ok) { const message = await response.text(); throw new Error(`BodyTracker fehlgeschlagen (${response.status}): ${message}`); } return await response.json(); } async function renderSnapshot() { const table = document.getElementById("snapshot-table"); const pictureEl = document.getElementById("snapshot-info-picture"); if (!table) return; try { const { data, headers, rows } = await fetchCSV(); // Info anzeigen const infoEl = document.getElementById("snapshot-info"); if (infoEl) { infoEl.textContent = `Datei: ${data.filename}, Geändert: ${new Date(data.mtime).toLocaleString()}, Zeilen: ${rows.length}`; } // Bild anzeigen, falls vorhanden if (pictureEl) { let imagesHtml = ''; if (data.imageFile) { imagesHtml += `${data.imageFile.filename}`; } if (data.image2) { imagesHtml += `${data.image2.filename}`; } if (imagesHtml) { pictureEl.innerHTML = `
${imagesHtml}
`; } else { pictureEl.innerHTML = ""; } } // Tabelle leeren table.innerHTML = ""; // Header const thead = document.createElement("thead"); const headerRow = document.createElement("tr"); headers.forEach(h => { const th = document.createElement("th"); th.textContent = h; headerRow.appendChild(th); }); thead.appendChild(headerRow); table.appendChild(thead); // Body const tbody = document.createElement("tbody"); rows.forEach(row => { const tr = document.createElement("tr"); headers.forEach(h => { const td = document.createElement("td"); let value = row[h]; if (typeof value === 'number') { if (h === 'id' || h === 'seen_by') { value = Math.round(value); } else { value = value.toFixed(1); } } td.textContent = value; tr.appendChild(td); }); tbody.appendChild(tr); }); table.appendChild(tbody); } catch (err) { console.error("Fehler beim Rendern des Snapshots:", err); } } async function onCalculateClick() { clearTextarea("analysis-log"); clearTextarea("result-json"); clearElement("result-tree"); clearElement("snapshot-table"); appendLog("Starte Berechnung..."); try { const response = await fetch("robot.json"); const robot = await response.json(); const snapshot = await fetchWebcamSnapshotData(); appendLog(`Snapshot geladen: ${snapshot.filename} (${snapshot.rows.length} Zeilen)`); let result = null; try { result = await sendToBodyTracker(snapshot); appendLog("BodyTracker wurde aufgerufen."); } catch (err) { appendLog(`BodyTracker nicht aufgerufen: ${err.message}`); } if (!result) { appendLog("Fallback: lokale Pose-Berechnung mit calculateAngles.js"); result = await window.calculate(snapshot.robotIntrinsics, robot); } renderResult(result); await renderSnapshot(); appendLog("Result angezeigt."); } catch (err) { appendLog(`Fehler: ${err.message}`); } } // ── Foto ───────────────────────────────────────────────────────────────────── /** * Foto-Button: holt HD-JPEGs aller 3 Kameras parallel * und zeigt sie nebeneinander im Snapshot-Bereich an. */ async function onFotoClick() { const display = document.getElementById('snapshot-info-picture'); if (!display) return; const camIds = ['cam0', 'cam1', 'cam2']; const labels = { cam0: 'front', cam1: 'left', cam2: 'right' }; appendLog('Foto: alle Kameras …'); const t = Date.now(); // Wrapper mit 3 Spalten const wrapper = document.createElement('div'); wrapper.style.cssText = 'display:flex; gap:6px; align-items:flex-start;'; camIds.forEach(id => { const col = document.createElement('div'); col.style.cssText = 'flex:1 1 0; min-width:0;'; const caption = document.createElement('div'); caption.style.cssText = 'font-size:0.8em; margin-bottom:3px; color:#aaa;'; caption.textContent = `${id} (${labels[id]})`; const img = document.createElement('img'); img.style.cssText = 'width:100%; height:auto; display:block;'; img.alt = id; img.onload = () => appendLog(` ${id}: ${img.naturalWidth}×${img.naturalHeight}px`); img.onerror = () => appendLog(` ${id}: Fehler`); img.src = `/api/webcam/snapshot/${id}?t=${t}`; col.appendChild(caption); col.appendChild(img); wrapper.appendChild(col); }); display.innerHTML = ''; display.appendChild(wrapper); } // ── Homing ──────────────────────────────────────────────────────────────────── let _homingState = null; function setHomingStatus(label, cls) { const el = document.getElementById('homing-status'); if (!el) return; el.textContent = label; el.className = `status-badge ${cls}`; } function setHomingProgress(step, total, text) { const wrap = document.getElementById('homing-progress'); const bar = document.getElementById('homing-progress-bar'); const txt = document.getElementById('homing-progress-text'); if (!wrap) return; wrap.style.display = 'block'; if (bar) bar.style.width = (total > 0 ? Math.round(step / total * 100) : 0) + '%'; if (txt) txt.textContent = text || `Schritt ${step} / ${total}`; } // Schreibt das G92-Kommando ins Eingabefeld. // - progressiv (full=false): nur die bereits bestimmten Achsen, je Gelenk-Update // - final (full=true): alle 7 Achsen; fehlende c (Palm) / e (Greifer) // werden als 0 ergänzt — identisch zu dem, was // "An Roboter senden" via server/buildG92.cjs sendet. function writePartialGCode(state, { full = false } = {}) { const axisMap = { x: 'X', y: 'Y', z: 'Z', a: 'A', b: 'B', c: 'C', e: 'E' }; const parts = []; for (const [key, axis] of Object.entries(axisMap)) { const num = Number(state[key]); if (state[key] != null && Number.isFinite(num)) { parts.push(`${axis}${num.toFixed(2)}`); } else if (full) { parts.push(`${axis}0.00`); } } if (!parts.length) return; const el = document.getElementById('gcodePayload'); if (el) el.value = `G92 ${parts.join(' ')}`; } function showHomingResult(state) { // Raw JSON const jsonEl = document.getElementById('result-json'); if (jsonEl) jsonEl.value = JSON.stringify(state, null, 2); // Tree View: Labels + Einheiten statt generischem renderTree const treeEl = document.getElementById('result-tree'); if (treeEl) { const LABELS = { x_mm: 'x (Slider)', y_deg: 'y (Arm1)', z_deg: 'z (Ellbow)', a_deg: 'a (Arm2)', b_deg: 'b (Hand)', c_deg: 'c (Palm)', e_mm: 'e (Greifer)', }; const UNITS = { x_mm: 'mm', y_deg: '°', z_deg: '°', a_deg: '°', b_deg: '°', c_deg: '°', e_mm: 'mm', }; let html = ''; for (const [key, val] of Object.entries(state)) { const label = LABELS[key] ?? key; const unit = UNITS[key] ?? ''; const valStr = typeof val === 'number' ? val.toFixed(2) : String(val ?? '–'); html += `
${label} ${valStr} ${unit}
`; } treeEl.innerHTML = html || ''; } } async function loadHomingImages(runDir) { if (!runDir) return; try { const res = await fetch(`/api/homing/run-data?run=${encodeURIComponent(runDir)}`); if (!res.ok) return; const data = await res.json(); // ── Debug-Bilder ────────────────────────────────────────────────────────── const display = document.getElementById('snapshot-info-picture'); if (display) { const debugImages = (data.images ?? []).filter(img => /debug/i.test(img.filename)); let html = '
'; for (const img of debugImages) { html += `
${img.filename}
${img.filename}
`; } html += '
'; display.innerHTML = html; } // ── Marker-CSV-Tabelle ──────────────────────────────────────────────────── if (data.csvContent) renderHomingCsv(data.csvContent, runDir); } catch { /* nicht kritisch */ } } function renderHomingCsv(csvContent, runDir) { const table = document.getElementById('snapshot-table'); const infoEl = document.getElementById('snapshot-info'); if (!table) return; const lines = csvContent.trim().split(/\r?\n/).filter(Boolean); if (lines.length < 2) return; const headers = lines[0].split(',').map(h => h.trim()); const rows = lines.slice(1).map(line => { const cells = line.split(','); const obj = {}; headers.forEach((h, i) => { const raw = (cells[i] ?? '').trim(); const num = Number(raw); obj[h] = raw !== '' && Number.isFinite(num) ? num : raw; }); return obj; }); if (infoEl) infoEl.textContent = `aruco_marker_poses.csv · ${runDir} · ${rows.length} Marker`; table.innerHTML = ''; const thead = document.createElement('thead'); const headerRow = document.createElement('tr'); headers.forEach(h => { const th = document.createElement('th'); th.textContent = h; headerRow.appendChild(th); }); thead.appendChild(headerRow); table.appendChild(thead); const tbody = document.createElement('tbody'); rows.forEach(row => { const tr = document.createElement('tr'); headers.forEach(h => { const td = document.createElement('td'); let v = row[h]; if (typeof v === 'number') { v = /^(marker_id|id|num_cameras|seen_by)$/.test(h) ? Math.round(v) : v.toFixed(1); } td.textContent = v ?? ''; tr.appendChild(td); }); tbody.appendChild(tr); }); table.appendChild(tbody); } async function runHoming() { // UI zurücksetzen clearTextarea('log'); clearTextarea('analysis-log'); clearTextarea('result-json'); clearElement('result-tree'); clearElement('snapshot-table'); clearElement('snapshot-info-picture'); const btnRun = document.getElementById('btn-homing-run'); const btnSend = document.getElementById('btn-homing-send'); _homingState = null; if (btnRun) btnRun.disabled = true; if (btnSend) { btnSend.disabled = true; btnSend.style.opacity = '.4'; btnSend.style.cursor = 'not-allowed'; } setHomingStatus('● Läuft …', 'wip'); setHomingProgress(0, 6, 'Verbinde …'); try { const response = await fetch('/api/homing/run', { method: 'POST' }); if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buf = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); const lines = buf.split('\n'); buf = lines.pop(); for (const line of lines) { if (!line.startsWith('data: ')) continue; let evt; try { evt = JSON.parse(line.slice(6)); } catch { continue; } switch (evt.type) { case 'log': appendLog(evt.text ?? ''); break; case 'step': setHomingProgress(evt.step, evt.total, evt.text); appendLog(`[${evt.step}/${evt.total}] ${evt.text || ''}`); break; case 'analysis': { const el = document.getElementById('analysis-log'); if (el) { el.value += `${evt.key}:\n${JSON.stringify(evt.value, null, 2)}\n\n`; el.scrollTop = el.scrollHeight; } // Progressiver Skeleton-Update: accumulated_state nach jedem Gelenk if (evt.key?.startsWith('state_') && evt.value) { const frame = document.getElementById('board-viewer-frame'); if (frame?.contentWindow) { frame.contentWindow.postMessage({ type: 'homing-state', state: evt.value }, '*'); } writePartialGCode(evt.value); } break; } case 'error': appendLog(evt.text ?? ''); setHomingStatus('✗ Fehler', 'open'); break; case 'done': if (evt.state) { _homingState = evt.state; showHomingResult(evt.state); // Vollständiges G92 (inkl. C0/E0) ins Feld — exakt das, was // "An Roboter senden" schickt. writePartialGCode(evt.state, { full: true }); if (btnSend) { btnSend.disabled = false; btnSend.style.opacity = ''; btnSend.style.cursor = ''; } setHomingStatus('✓ Fertig', 'done'); setHomingProgress(6, 6, 'Homing abgeschlossen'); } else { setHomingStatus('✗ Fehler', 'open'); setHomingProgress(6, 6, 'Fehler aufgetreten'); } if (evt.runDir) await loadHomingImages(evt.runDir); const frame = document.getElementById('board-viewer-frame'); if (frame?.contentWindow) { frame.contentWindow.postMessage({ type: 'load-homing-run', runDir: evt.runDir }, '*'); if (evt.state) { frame.contentWindow.postMessage({ type: 'homing-state', state: evt.state }, '*'); } } break; } } } } catch (err) { appendLog(`❌ Verbindungsfehler: ${err.message}`); setHomingStatus('✗ Fehler', 'open'); } finally { if (btnRun) btnRun.disabled = false; } } async function sendHomingToRobot() { if (!_homingState) return; const btnSend = document.getElementById('btn-homing-send'); if (btnSend) btnSend.disabled = true; try { const res = await fetch('/api/homing/send-state', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ state: _homingState }), }); const data = await res.json(); if (res.ok) { appendLog(`✅ An Roboter gesendet: ${data.gcode ?? ''}`); if (data.note) appendLog(`ℹ ${data.note}`); setHomingStatus('✓ Gesendet', 'done'); } else { appendLog(`❌ Fehler beim Senden: ${data.error ?? JSON.stringify(data)}`); if (btnSend) btnSend.disabled = false; } } catch (err) { appendLog(`❌ Netzwerkfehler: ${err.message}`); if (btnSend) btnSend.disabled = false; } } // Transport für die G-Code-/Befehl-Buttons (data-cmd). Schickt eine rohe // Zeile über das Backend an den Driver-WebSocket (POST /api/robot/gcode). // Liegt ein Payload-Feld vor (z.B. das G92 aus #gcodePayload), wird dessen // Inhalt gesendet, sonst der cmd-Name selbst. Ersetzt den toten WSS-Altpfad. window.sendCommand = async function (cmd, payload) { const line = (payload && payload.trim()) ? payload.trim() : String(cmd ?? '').trim(); if (!line) throw new Error('Leere Befehlszeile'); const res = await fetch('/api/robot/gcode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ line }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`); return data; }; async function onCommandClick(btn) { const cmd = btn.dataset.cmd; const payloadSelector = btn.dataset.payload; const payload = payloadSelector ? document.querySelector(payloadSelector)?.value ?? "" : ""; if (typeof window.sendCommand === "function") { try { await window.sendCommand(cmd, payload); appendLog(`Command gesendet: ${cmd}${payload ? " " + payload : ""}`); } catch (err) { appendLog(`Command-Fehler: ${err.message}`); } return; } appendLog(`Command (kein Transport definiert): ${cmd}${payload ? " " + payload : ""}`); } function setupUi() { // Homing-Buttons const homingRunBtn = document.getElementById('btn-homing-run'); if (homingRunBtn) homingRunBtn.addEventListener('click', runHoming); const homingSendBtn = document.getElementById('btn-homing-send'); if (homingSendBtn) homingSendBtn.addEventListener('click', sendHomingToRobot); // Ältere Buttons (Fallback / Debug) const calculateBtn = document.getElementById("btn-calculate"); if (calculateBtn) { calculateBtn.addEventListener("click", onCalculateClick); } const fotoBtn = document.getElementById("btn-foto"); if (fotoBtn) { fotoBtn.addEventListener("click", onFotoClick); } document.querySelectorAll("button[data-cmd]").forEach(btn => { if (btn.id === "btn-calculate") return; btn.addEventListener("click", () => onCommandClick(btn)); }); // ===== SECTION COLLAPSE/EXPAND ===== document.querySelectorAll(".section h2").forEach(heading => { heading.addEventListener("click", () => { const section = heading.closest(".section"); if (section) { section.classList.toggle("collapsed"); } }); heading.style.cursor = "pointer"; }); // Auto-expand when content is written to a collapsed section const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === "childList" || mutation.type === "characterData") { let el = mutation.target; while (el && el !== document.body) { const section = el.closest(".section"); if (section && section.classList.contains("collapsed")) { section.classList.remove("collapsed"); break; } el = el.parentElement; } } }); }); // Observer auf alle Elemente in Sections anwenden document.querySelectorAll(".section").forEach(section => { observer.observe(section, { childList: true, subtree: true, characterData: true, characterDataOldValue: false }); }); } window.addEventListener("DOMContentLoaded", setupUi);