// Application Configuration
const PRICING_DATABASE = {
"apfel_elstar": { name: "Apfel Elstar", price: 2.99, unit: "kg" },
"apfel_gala": { name: "Apfel Gala", price: 2.49, unit: "kg" },
"apfel_granny_smith": { name: "Apfel Granny Smith", price: 2.79, unit: "kg" },
"banane_chiquita": { name: "Banane Chiquita", price: 1.99, unit: "kg" },
"banane_bio": { name: "Bio Banane", price: 2.29, unit: "kg" },
"birne": { name: "Birne Abate Fetel", price: 3.29, unit: "kg" },
"orange": { name: "Orangen", price: 2.19, unit: "kg" },
"zitrone": { name: "Zitronen", price: 0.59, unit: "Stk." },
"limette": { name: "Limetten", price: 0.49, unit: "Stk." },
"erdbeere": { name: "Erdbeeren Schale", price: 2.99, unit: "Stk." },
"blaubeere": { name: "Kulturheidelbeeren", price: 1.99, unit: "Stk." },
"weintraube_hell": { name: "Tafeltrauben hell", price: 3.99, unit: "kg" },
"weintraube_dunkel": { name: "Tafeltrauben dunkel", price: 4.29, unit: "kg" },
"pfirsich": { name: "Pfirsiche", price: 2.99, unit: "kg" },
"tomate": { name: "Rispentomaten", price: 3.49, unit: "kg" },
"gurke": { name: "Salatgurke", price: 0.99, unit: "Stk." },
"kartoffel": { name: "Speisekartoffeln", price: 1.49, unit: "kg" },
"karotte": { name: "Speisemöhren", price: 1.29, unit: "kg" },
"zwiebel_gelb": { name: "Speisezwiebeln", price: 1.19, unit: "kg" },
"zwiebel_rot": { name: "Rote Zwiebeln", price: 1.69, unit: "kg" },
"knoblauch": { name: "Knoblauch", price: 0.89, unit: "Stk." },
"brokkoli": { name: "Brokkoli", price: 1.79, unit: "Stk." },
"paprika_rot": { name: "Paprika rot", price: 3.99, unit: "kg" },
"paprika_gelb": { name: "Paprika gelb", price: 3.99, unit: "kg" },
"paprika_gruen": { name: "Paprika grün", price: 3.49, unit: "kg" },
"champignon": { name: "Champignons weiß", price: 1.89, unit: "Stk." },
"zucchini": { name: "Zucchini", price: 2.19, unit: "kg" },
"avocado": { name: "Avocado Hass", price: 1.29, unit: "Stk." }
};
// Application State
let activeTab = 'checkout';
let isWebcamActive = false;
let webcamStream = null;
let simulatedStreamInterval = null;
let currentFrameBase64 = null;
let isDetecting = false;
let autoDetectEnabled = true;
let trainingPollInterval = null;
// Scanner Emulator State
let autoScanEnabled = true;
let stableClass = null;
let stableCount = 0;
const STABILITY_THRESHOLD = 4; // number of frames (approx 0.8s)
let lastScannedClass = null;
let scanLockActive = false;
let articlesDatabase = {};
// Cart State
let shoppingCart = [];
// DOM Elements
const videoEl = document.getElementById("webcam");
const canvasEl = document.getElementById("camera-canvas");
const ctx = canvasEl.getContext("2d");
const toggleCameraBtn = document.getElementById("toggle-camera-btn");
const triggerScaleBtn = document.getElementById("trigger-scale-btn");
const autoDetectToggle = document.getElementById("auto-detect-toggle");
const cameraTypeBadge = document.getElementById("camera-type-badge");
const quickSelectButtons = document.getElementById("quick-selection-buttons");
const cartList = document.getElementById("cart-list");
const cartSubtotalEl = document.getElementById("cart-subtotal");
const cartTotalEl = document.getElementById("cart-total");
const emptyCartMsg = document.getElementById("empty-cart-msg");
// --- TAB NAVIGATION ---
function switchTab(tabName) {
activeTab = tabName;
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.nav-btn').forEach(el => el.classList.remove('active'));
document.getElementById(`tab-${tabName}`).classList.add('active');
document.getElementById(`tab-btn-${tabName}`).classList.add('active');
if (tabName === 'dashboard') {
// Start polling training status when entering dashboard
startTrainingPolling();
refreshAugmentedPreview();
} else {
stopTrainingPolling();
}
if (tabName === 'articles') {
loadArticlesDatabase();
}
}
// --- CAMERA & DETECTION LOGIC ---
async function startWebcam() {
try {
webcamStream = await navigator.mediaDevices.getUserMedia({
video: { width: 640, height: 480, facingMode: "environment" }
});
videoEl.srcObject = webcamStream;
videoEl.style.display = "none";
isWebcamActive = true;
cameraTypeBadge.textContent = "LIVE-WEBCAM";
cameraTypeBadge.style.background = "var(--color-primary)";
toggleCameraBtn.innerHTML = `
Kamera deaktivieren
`;
document.getElementById("stream-status").textContent = "LIVE";
document.getElementById("stream-status").classList.add("live");
// Stop simulated stream if active
if (simulatedStreamInterval) {
clearInterval(simulatedStreamInterval);
simulatedStreamInterval = null;
}
requestAnimationFrame(processWebcamFrame);
} catch (err) {
console.error("Webcam access denied/unavailable:", err);
alert("Webcam konnte nicht gestartet werden. Der simulierte Kamera-Modus wird verwendet.");
startSimulatedStream();
}
}
function stopWebcam() {
if (webcamStream) {
webcamStream.getTracks().forEach(track => track.stop());
webcamStream = null;
}
videoEl.srcObject = null;
videoEl.style.display = "none";
isWebcamActive = false;
toggleCameraBtn.innerHTML = `
Webcam aktivieren
`;
startSimulatedStream();
}
function processWebcamFrame() {
if (!isWebcamActive) return;
// Draw current video frame to canvas
ctx.drawImage(videoEl, 0, 0, canvasEl.width, canvasEl.height);
// Get frame as base64
currentFrameBase64 = canvasEl.toDataURL("image/jpeg", 0.7);
// Auto-detect if enabled
if (autoDetectEnabled && !isDetecting) {
runDetection();
}
setTimeout(() => {
requestAnimationFrame(processWebcamFrame);
}, 150); // Limit detection frequency for performance
}
function startSimulatedStream() {
cameraTypeBadge.textContent = "SIMULATIONS-MODUS";
cameraTypeBadge.style.background = "var(--color-bg-dark)";
document.getElementById("stream-status").textContent = "Simuliert";
document.getElementById("stream-status").classList.add("live");
const loadSimulatedFrame = async () => {
try {
const response = await fetch("/api/simulated_frame");
const data = await response.json();
if (data.status === "success" && data.image) {
const img = new Image();
img.onload = () => {
ctx.drawImage(img, 0, 0, canvasEl.width, canvasEl.height);
currentFrameBase64 = data.image;
if (autoDetectEnabled && !isDetecting) {
runDetection();
}
};
img.src = data.image;
}
} catch (err) {
console.error("Failed to load simulated frame:", err);
}
};
loadSimulatedFrame(); // First load immediately
simulatedStreamInterval = setInterval(loadSimulatedFrame, 2500); // cycle frames every 2.5s
}
// Perform detection API request
async function runDetection() {
if (!currentFrameBase64) return;
isDetecting = true;
try {
const response = await fetch("/api/detect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image: currentFrameBase64 })
});
const data = await response.json();
if (data.status === "success") {
drawDetections(data.predictions);
updateQuickButtons(data.predictions);
processStabilityAndScan(data.predictions);
}
} catch (err) {
console.error("Detection error:", err);
} finally {
isDetecting = false;
}
}
// Draw bounding boxes on canvas
function drawDetections(predictions) {
// Redraw the base frame first (to clear old boxes)
const img = new Image();
img.onload = () => {
ctx.drawImage(img, 0, 0, canvasEl.width, canvasEl.height);
// Draw boxes
predictions.forEach(pred => {
const [xmin, ymin, xmax, ymax] = pred.box;
const w = xmax - xmin;
const h = ymax - ymin;
const isHighConf = pred.confidence > 0.70;
// Box outline
ctx.lineWidth = 3;
ctx.strokeStyle = isHighConf ? "#10b981" : "#c084fc"; // Green for high conf, purple for low conf
ctx.strokeRect(xmin, ymin, w, h);
// Label box background
ctx.fillStyle = isHighConf ? "rgba(16, 185, 129, 0.85)" : "rgba(168, 85, 247, 0.85)";
const text = `${PRICING_DATABASE[pred.class]?.name || pred.class} (${(pred.confidence * 100).toFixed(0)}%)`;
ctx.font = "bold 12px sans-serif";
const textWidth = ctx.measureText(text).width;
ctx.fillRect(xmin - 1, ymin - 22, textWidth + 12, 22);
// Label text
ctx.fillStyle = "#ffffff";
ctx.fillText(text, xmin + 5, ymin - 6);
});
};
img.src = currentFrameBase64;
}
// Update cashier recommendation buttons
function updateQuickButtons(predictions) {
quickSelectButtons.innerHTML = "";
// Filter predictions with confidence > 0.10
const candidates = predictions.filter(p => p.confidence > 0.10);
if (candidates.length === 0) {
quickSelectButtons.innerHTML = `
Keine Objekte erkannt
Confidence Schwellenwert unter 10%
`;
return;
}
candidates.forEach(pred => {
const itemInfo = PRICING_DATABASE[pred.class] || { name: pred.class, price: 1.0, unit: "kg" };
const confPercent = (pred.confidence * 100).toFixed(0);
const isHigh = pred.confidence > 0.70;
const btn = document.createElement("button");
btn.className = `quick-btn ${isHigh ? 'high-conf' : 'low-conf'}`;
btn.onclick = () => sendEanToPos(pred.class);
btn.innerHTML = `
${itemInfo.name}
${confPercent}% Conf
`;
quickSelectButtons.appendChild(btn);
});
}
// --- SHOPPING CART LOGIC ---
function addToCart(classKey) {
const itemInfo = PRICING_DATABASE[classKey];
if (!itemInfo) return;
// Simulate weight or piece count
let quantity, itemTotal;
if (itemInfo.unit === "kg") {
// Random weight between 0.150kg and 1.800kg
quantity = parseFloat((0.150 + Math.random() * 1.65).toFixed(3));
itemTotal = parseFloat((quantity * itemInfo.price).toFixed(2));
} else {
quantity = 1;
itemTotal = itemInfo.price;
}
const cartItem = {
key: classKey,
name: itemInfo.name,
pricePerUnit: itemInfo.price,
unit: itemInfo.unit,
quantity: quantity,
total: itemTotal,
timestamp: Date.now()
};
shoppingCart.push(cartItem);
updateCartUI();
// Scale animation feedback
const scaleCard = document.querySelector(".camera-card");
scaleCard.classList.add("scale-flash");
setTimeout(() => scaleCard.classList.remove("scale-flash"), 400);
}
function deleteCartItem(timestamp) {
shoppingCart = shoppingCart.filter(item => item.timestamp !== timestamp);
updateCartUI();
}
function clearCart() {
shoppingCart = [];
updateCartUI();
}
function checkoutCart() {
if (shoppingCart.length === 0) return;
const total = cartTotalEl.textContent;
alert(`Zahlung erfolgreich abgeschlossen!\nBetrag: ${total}\nKassenzettel gedruckt.`);
clearCart();
}
function updateCartUI() {
cartList.innerHTML = "";
if (shoppingCart.length === 0) {
emptyCartMsg.style.display = "flex";
cartSubtotalEl.textContent = "0,00 €";
cartTotalEl.textContent = "0,00 €";
return;
}
emptyCartMsg.style.display = "none";
let subtotal = 0;
shoppingCart.forEach(item => {
subtotal += item.total;
const row = document.createElement("div");
row.className = "cart-item-row";
const details = document.createElement("div");
details.className = "item-details";
const name = document.createElement("span");
name.className = "item-name";
name.textContent = item.name;
const meta = document.createElement("span");
meta.className = "item-meta";
if (item.unit === "kg") {
meta.textContent = `${item.quantity.toFixed(3)} kg x ${item.pricePerUnit.toFixed(2)} €/kg`;
} else {
meta.textContent = `${item.quantity} Stk. x ${item.pricePerUnit.toFixed(2)} €/Stk.`;
}
details.appendChild(name);
details.appendChild(meta);
const actions = document.createElement("div");
actions.className = "item-price-actions";
const price = document.createElement("span");
price.className = "item-price";
price.textContent = `${item.total.toFixed(2).replace('.', ',')} €`;
const delBtn = document.createElement("button");
delBtn.className = "delete-item-btn";
delBtn.onclick = () => deleteCartItem(item.timestamp);
delBtn.innerHTML = `
`;
actions.appendChild(price);
actions.appendChild(delBtn);
row.appendChild(details);
row.appendChild(actions);
cartList.appendChild(row);
});
const tax = subtotal * 0.07; // 7% vat
cartSubtotalEl.textContent = `${subtotal.toFixed(2).replace('.', ',')} €`;
cartTotalEl.textContent = `${subtotal.toFixed(2).replace('.', ',')} €`;
}
// --- DEVELOPER DASHBOARD BACKEND CALLS ---
// 1. Synthetic dataset trigger
async function generateDataset() {
try {
const response = await fetch("/api/generate_dataset", { method: "POST" });
const data = await response.json();
alert(data.message);
startTrainingPolling(); // will start checking generation status
} catch (err) {
console.error("Failed to generate dataset:", err);
}
}
// 2. Refresh Albumentations augmented preview
async function refreshAugmentedPreview() {
const previewBox = document.getElementById("aug-preview-box");
previewBox.innerHTML = `Generiere Vorschau...
`;
try {
const response = await fetch("/api/augmented_preview");
const data = await response.json();
if (data.status === "success") {
previewBox.innerHTML = `
`;
} else {
previewBox.innerHTML = `Fehler: ${data.message}
`;
}
} catch (err) {
previewBox.innerHTML = `Keine Trainingsbilder vorhanden.
Bitte zuerst "Datensatz generieren" klicken.
`;
}
}
// 3. Trigger model training
async function startTraining() {
const epochs = parseInt(document.getElementById("param-epochs").value) || 15;
const batchSize = parseInt(document.getElementById("param-batch").value) || 8;
try {
const response = await fetch("/api/train", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ epochs: epochs, batch_size: batchSize })
});
const data = await response.json();
alert(data.message);
startTrainingPolling();
} catch (err) {
console.error("Failed to start training:", err);
}
}
// Poll training API
function startTrainingPolling() {
if (trainingPollInterval) return;
const checkStatus = async () => {
try {
const response = await fetch("/api/train_status");
const data = await response.json();
updateTrainingStatusUI(data);
// If training or data generation completed or failed, we can stop/keep checking depending on state
if (data.status === "completed" || data.status === "failed" || data.status === "idle") {
// Keep checking periodically but slower, or stop if we want to
}
} catch (err) {
console.error("Status check failed:", err);
}
};
checkStatus();
trainingPollInterval = setInterval(checkStatus, 1500);
}
function stopTrainingPolling() {
if (trainingPollInterval) {
clearInterval(trainingPollInterval);
trainingPollInterval = null;
}
}
// Update training UI and plot graphs
function updateTrainingStatusUI(data) {
const trainingState = document.getElementById("training-state");
const trainingEpoch = document.getElementById("training-epoch");
const progressFill = document.getElementById("training-progress-fill");
const logMsg = document.getElementById("training-log-msg");
const statusBox = document.getElementById("training-status-box");
// Update status labels
trainingState.textContent = getGermanState(data.status);
trainingEpoch.textContent = data.epoch > 0 ? `Epoche: ${data.epoch}/${data.total_epochs}` : "Epoche: -/-";
progressFill.style.width = `${data.progress}%`;
logMsg.textContent = data.message;
// Update box glow styling
statusBox.className = "training-status-box";
if (data.status === "training") {
statusBox.classList.add("active-pulse");
}
// Plot Chart if loss history is available
if (data.train_loss && data.train_loss.length > 0) {
updateChart(data.train_loss, data.val_loss || []);
}
}
function getGermanState(status) {
switch (status) {
case "idle": return "Bereit";
case "generating_data": return "Generiere Daten...";
case "training": return "Training läuft...";
case "completed": return "Abgeschlossen";
case "failed": return "Fehlgeschlagen";
default: return status;
}
}
// Plot losses in SVG
function updateChart(trainLosses, valLosses) {
const svgWidth = 440; // width bounds inside SVG 500
const svgHeight = 150; // height bounds inside SVG 200
if (trainLosses.length === 0) return;
// Find maximum loss to scale Y-axis correctly
const allLosses = [...trainLosses, ...valLosses];
const maxLoss = Math.max(2.0, ...allLosses);
const getSvgCoords = (losses) => {
return losses.map((loss, idx) => {
const x = 40 + (idx / (losses.length - 1 || 1)) * svgWidth;
const y = 170 - (loss / maxLoss) * svgHeight;
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
};
const trainPoints = getSvgCoords(trainLosses);
document.getElementById("train-loss-path").setAttribute("d", "M " + trainPoints.join(" L "));
if (valLosses.length > 0) {
const valPoints = getSvgCoords(valLosses);
document.getElementById("val-loss-path").setAttribute("d", "M " + valPoints.join(" L "));
}
}
// 4. Export model to ONNX
async function exportModel() {
const onnxLog = document.getElementById("onnx-log");
const exportBtn = document.getElementById("export-onnx-btn");
const latencyVal = document.getElementById("onnx-latency");
const latencyStatus = document.getElementById("onnx-latency-status");
onnxLog.textContent = "Starte ONNX Export & Benchmarking...";
exportBtn.disabled = true;
try {
const response = await fetch("/api/export", { method: "POST" });
const data = await response.json();
exportBtn.disabled = false;
if (data.status === "success") {
onnxLog.textContent = data.log || "Export erfolgreich!";
// Extract latency from benchmark logs using regex
const match = data.log.match(/Average ONNX Runtime inference latency: ([\d\.]+) ms/);
if (match && match[1]) {
const latency = match[1];
latencyVal.textContent = `${latency} ms`;
latencyStatus.textContent = parseFloat(latency) < 50.0
? "✓ < 50 ms (Flüssige Live-Ansicht)"
: "⚠️ > 50 ms (Optimiere Hardware)";
} else {
latencyVal.textContent = "< 30 ms";
latencyStatus.textContent = "✓ Inferenz läuft flüssig";
}
alert("Modell erfolgreich in ONNX konvertiert und geladen!");
} else {
onnxLog.textContent = `Fehler: ${data.message}`;
alert(`Export fehlgeschlagen: ${data.message}`);
}
} catch (err) {
exportBtn.disabled = false;
onnxLog.textContent = `Fehler bei der Server-Kommunikation.`;
alert("Export-Anfrage fehlgeschlagen. Vergewissere dich, dass ein PyTorch Modell (.pt) existiert.");
}
}
async function captureForTraining() {
if (!isWebcamActive) {
alert("Bitte aktivieren Sie zuerst die Live-Webcam, um ein echtes Foto aufzunehmen.");
return;
}
const classSelect = document.getElementById("capture-class-select");
const selectedClass = classSelect.value;
if (!selectedClass) return;
const captureBtn = document.getElementById("capture-image-btn");
captureBtn.disabled = true;
// Capture current frame from canvas (high quality)
const base64Frame = canvasEl.toDataURL("image/jpeg", 0.95);
try {
const response = await fetch("/api/capture_training_image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image: base64Frame, class_name: selectedClass })
});
const data = await response.json();
captureBtn.disabled = false;
if (data.status === "success") {
// Update display count
document.getElementById("capture-count-display").textContent = `Eigene Bilder: ${data.count}`;
// Flash camera box as visual feedback
const container = document.querySelector(".camera-card");
container.classList.add("scale-flash");
setTimeout(() => container.classList.remove("scale-flash"), 300);
console.log(data.message);
} else {
alert(`Fehler bei Aufnahme: ${data.message}`);
}
} catch (err) {
captureBtn.disabled = false;
console.error("Capture request failed:", err);
alert("Serverfehler bei Bild-Erfassung.");
}
}
async function initCapturePanel() {
const classSelect = document.getElementById("capture-class-select");
if (!classSelect) return;
classSelect.innerHTML = "";
Object.keys(PRICING_DATABASE).forEach(key => {
const opt = document.createElement("option");
opt.value = key;
opt.textContent = PRICING_DATABASE[key].name;
if (key === "kartoffel") {
opt.selected = true; // default to kartoffel
}
classSelect.appendChild(opt);
});
try {
const response = await fetch("/api/capture_count");
const data = await response.json();
document.getElementById("capture-count-display").textContent = `Eigene Bilder: ${data.count}`;
} catch (err) {
console.error("Failed to load initial capture count:", err);
}
}
// --- VIRTUAL BARCODE SCANNER LOGIC ---
// Initial load of EAN database on startup
async function initArticles() {
try {
const response = await fetch("/api/articles");
const data = await response.json();
articlesDatabase = data;
// Sync names back to PRICING_DATABASE
Object.keys(articlesDatabase).forEach(key => {
if (!PRICING_DATABASE[key]) {
PRICING_DATABASE[key] = { name: articlesDatabase[key].name, price: 1.99, unit: "Stk." };
} else {
PRICING_DATABASE[key].name = articlesDatabase[key].name;
}
});
} catch (err) {
console.error("Fehler beim Initialisieren der Artikeldatenbank:", err);
}
}
// Render articles table rows in Article Management tab
function renderArticlesTable() {
const tbody = document.getElementById("articles-table-body");
if (!tbody) return;
tbody.innerHTML = "";
Object.keys(articlesDatabase).forEach(key => {
const article = articlesDatabase[key];
const tr = document.createElement("tr");
tr.style.borderBottom = "1px solid var(--border-glass)";
tr.innerHTML = `
${key} |
|
|
`;
tbody.appendChild(tr);
});
}
// Load articles table in Article Management tab
async function loadArticlesDatabase() {
try {
const response = await fetch("/api/articles");
const data = await response.json();
articlesDatabase = data;
// Sync to PRICING_DATABASE
Object.keys(articlesDatabase).forEach(key => {
if (!PRICING_DATABASE[key]) {
PRICING_DATABASE[key] = { name: articlesDatabase[key].name, price: 1.99, unit: "Stk." };
} else {
PRICING_DATABASE[key].name = articlesDatabase[key].name;
}
});
renderArticlesTable();
} catch (err) {
console.error("Fehler beim Laden der Artikeldatenbank:", err);
}
}
// Prompt to add a new article key and EAN code
function addNewArticlePrompt() {
const key = prompt("Geben Sie einen eindeutigen Systemschlüssel für den Artikel ein (z.B. 'mango', nur Kleinbuchstaben und Unterstriche):");
if (!key) return;
// Validate key: lowercase, letters, numbers, underscores
const keyRegex = /^[a-z0-9_]+$/;
if (!keyRegex.test(key)) {
alert("Ungültiger Schlüssel. Bitte nur Kleinbuchstaben (a-z), Zahlen (0-9) und Unterstriche (_) verwenden.");
return;
}
if (PRICING_DATABASE[key] || articlesDatabase[key]) {
alert("Dieser Artikel-Schlüssel existiert bereits.");
return;
}
const name = prompt("Geben Sie den Anzeigenamen für den Artikel ein (z.B. 'Mango'):");
if (!name) return;
const ean = prompt("Geben Sie den EAN-Barcode für die Tastaturemulation ein (z.B. '4001234000292'):");
if (!ean) return;
// Add to local articlesDatabase and PRICING_DATABASE
articlesDatabase[key] = { name: name, ean: ean };
PRICING_DATABASE[key] = { name: name, price: 1.99, unit: "Stk." };
// Reload the table and capture dropdown
renderArticlesTable();
initCapturePanel();
}
// Save articles database
async function saveArticlesDatabase() {
const tbody = document.getElementById("articles-table-body");
if (!tbody) return;
const nameInputs = tbody.querySelectorAll(".article-name-input");
const eanInputs = tbody.querySelectorAll(".article-ean-input");
const updatedArticles = {};
nameInputs.forEach((input, index) => {
const key = input.getAttribute("data-key");
const name = input.value.trim();
const ean = eanInputs[index].value.trim();
updatedArticles[key] = { name, ean };
});
try {
const response = await fetch("/api/articles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ articles: updatedArticles })
});
const data = await response.json();
if (data.status === "success") {
articlesDatabase = updatedArticles;
// Sync updated articles to PRICING_DATABASE
Object.keys(updatedArticles).forEach(key => {
if (!PRICING_DATABASE[key]) {
PRICING_DATABASE[key] = { name: updatedArticles[key].name, price: 1.99, unit: "Stk." };
} else {
PRICING_DATABASE[key].name = updatedArticles[key].name;
}
});
// Update capture dropdown
initCapturePanel();
alert("Artikeldatenbank erfolgreich gespeichert!");
} else {
alert("Fehler beim Speichern: " + data.message);
}
} catch (err) {
console.error("Fehler beim Speichern der Artikeldatenbank:", err);
alert("Serverfehler beim Speichern der Artikeldatenbank.");
}
}
// Play high-pitched scanner beep
function playScannerBeep() {
try {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioCtx.createOscillator();
const gainNode = audioCtx.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioCtx.destination);
oscillator.type = "sine";
oscillator.frequency.setValueAtTime(1000, audioCtx.currentTime); // 1kHz beep
gainNode.gain.setValueAtTime(0, audioCtx.currentTime);
gainNode.gain.linearRampToValueAtTime(0.2, audioCtx.currentTime + 0.01);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.15);
oscillator.start(audioCtx.currentTime);
oscillator.stop(audioCtx.currentTime + 0.16);
} catch (err) {
console.error("Audio Context failed to play beep:", err);
}
}
// Add log message to the terminal container
function addScanLog(msg) {
const logEl = document.getElementById("scanner-log");
if (!logEl) return;
const timeStr = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const logLine = `[${timeStr}] ${msg}`;
if (logEl.textContent === "Warte auf Erkennung...") {
logEl.textContent = logLine;
} else {
logEl.textContent += "\\n" + logLine;
}
logEl.scrollTop = logEl.scrollHeight;
}
// Send EAN code to backend scanner endpoint
async function sendEanToPos(classKey) {
const article = articlesDatabase[classKey] || { name: PRICING_DATABASE[classKey]?.name || classKey, ean: "" };
if (!article.ean) {
addScanLog(`⚠️ Fehler: Kein EAN-Code für ${article.name} definiert.`);
return;
}
addScanLog(`Scanne ${article.name} (EAN: ${article.ean})...`);
try {
const response = await fetch("/api/scan_ean", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ class_name: classKey })
});
const data = await response.json();
if (response.ok && data.status === "success") {
playScannerBeep();
addScanLog(`✓ EAN ${article.ean} erfolgreich gesendet.`);
// Visual flash feedback on scanner card
const scannerCard = document.querySelector(".scanner-card");
if (scannerCard) {
scannerCard.classList.add("scale-flash");
setTimeout(() => scannerCard.classList.remove("scale-flash"), 400);
}
} else {
addScanLog(`❌ Fehler: ${data.message || "Tastaturemulation fehlgeschlagen"}`);
}
} catch (err) {
console.error("Scan API error:", err);
addScanLog(`❌ Serverfehler beim Senden des EAN-Codes.`);
}
}
// Stability filter logic
async function processStabilityAndScan(predictions) {
const autoScanToggle = document.getElementById("auto-scan-toggle");
const autoScan = autoScanToggle ? autoScanToggle.checked : true;
// Find the prediction with the highest confidence
let topPrediction = null;
if (predictions && predictions.length > 0) {
predictions.forEach(p => {
if (!topPrediction || p.confidence > topPrediction.confidence) {
topPrediction = p;
}
});
}
const scannerIndicator = document.getElementById("scanner-indicator");
const progressWrapper = document.getElementById("stability-progress-wrapper");
const progressFill = document.getElementById("stability-progress-fill");
const STABLE_CONF_THRESHOLD = 0.75;
if (topPrediction && topPrediction.confidence >= STABLE_CONF_THRESHOLD) {
const detectedClass = topPrediction.class;
if (stableClass !== detectedClass) {
stableClass = detectedClass;
stableCount = 1;
} else {
stableCount++;
}
if (progressWrapper && progressFill) {
progressWrapper.style.display = "block";
const percent = Math.min(100, (stableCount / STABILITY_THRESHOLD) * 100);
progressFill.style.width = `${percent}%`;
}
if (scannerIndicator) {
const articleName = PRICING_DATABASE[detectedClass]?.name || detectedClass;
scannerIndicator.textContent = `Erkenne ${articleName}... (${stableCount}/${STABILITY_THRESHOLD})`;
scannerIndicator.style.color = "var(--color-accent)";
}
if (stableCount >= STABILITY_THRESHOLD) {
if (lastScannedClass !== detectedClass && !scanLockActive) {
if (autoScan) {
await sendEanToPos(detectedClass);
} else {
addScanLog(`[Auto-Scan aus] ${PRICING_DATABASE[detectedClass]?.name || detectedClass} stabil erkannt.`);
}
lastScannedClass = detectedClass;
scanLockActive = true;
}
if (progressFill) progressFill.style.width = "0%";
if (progressWrapper) progressWrapper.style.display = "none";
if (scannerIndicator) {
scannerIndicator.textContent = `Gesperrt (Waage leeren)`;
scannerIndicator.style.color = "var(--color-danger)";
}
}
} else {
const CLEAR_THRESHOLD = 0.15;
if (!topPrediction || topPrediction.confidence < CLEAR_THRESHOLD) {
stableClass = null;
stableCount = 0;
scanLockActive = false;
lastScannedClass = null;
if (progressWrapper) progressWrapper.style.display = "none";
if (progressFill) progressFill.style.width = "0%";
if (scannerIndicator) {
scannerIndicator.textContent = "Bereit (Warte auf Objekt)";
scannerIndicator.style.color = "var(--color-success)";
}
} else {
stableCount = Math.max(0, stableCount - 1);
if (progressWrapper && progressFill) {
const percent = (stableCount / STABILITY_THRESHOLD) * 100;
progressFill.style.width = `${percent}%`;
}
if (scannerIndicator && stableCount > 0) {
scannerIndicator.textContent = `Signal instabil...`;
scannerIndicator.style.color = "var(--color-warning)";
} else if (scannerIndicator && !scanLockActive) {
scannerIndicator.textContent = "Bereit (Signal instabil)";
scannerIndicator.style.color = "var(--color-success)";
}
}
}
}
// Manual scan trigger
async function triggerManualScan() {
if (stableClass) {
addScanLog(`Manueller Scan ausgelöst für ${PRICING_DATABASE[stableClass]?.name || stableClass}...`);
await sendEanToPos(stableClass);
} else {
addScanLog(`⚠️ Kein stabiles Objekt auf der Waage. Manueller Scan abgebrochen.`);
alert("Bitte legen Sie ein Produkt auf die Waage, um es zu scannen.");
}
}
// --- INTERACTIVE EVENT LISTENERS ---
toggleCameraBtn.addEventListener("click", () => {
if (isWebcamActive) {
stopWebcam();
} else {
startWebcam();
}
});
triggerScaleBtn.addEventListener("click", () => {
// Flash scale overlay on manual trigger
const overlay = document.querySelector(".camera-card");
overlay.classList.add("scale-flash");
setTimeout(() => overlay.classList.remove("scale-flash"), 400);
// Explicitly run detection
runDetection();
});
autoDetectToggle.addEventListener("change", (e) => {
autoDetectEnabled = e.target.checked;
});
// App Startup Initialization
window.addEventListener("DOMContentLoaded", () => {
// Start in simulated video mode by default
startSimulatedStream();
// Fetch articles from database
initArticles();
// Read parameters from local values
updateCartUI();
// Initialize capture classes
initCapturePanel();
});