From 1b8e51fbb40d2435643c97d5a452d4f23126b3ad Mon Sep 17 00:00:00 2001 From: DanielS Date: Mon, 19 Oct 2026 20:48:09 +0200 Subject: [PATCH] Fix double scans and make POS TCP target configurable - Send EAN via TCP first; use keyboard emulation only as fallback when TCP fails (POS_KEYBOARD_FALLBACK=0 disables it), so the register never receives the same code twice - Read POS host/port/timeout from POS_TCP_HOST, POS_TCP_PORT, POS_TCP_TIMEOUT; add GET /api/scanner_config - /api/detect reports object_present from background subtraction - Keep the scan lock until the scale is really empty (2 empty frames with calibrated background, 8 without) instead of releasing on one low-confidence frame - Set the scan lock before awaiting the send to avoid duplicate sends from frames arriving while the request is in flight - Keep HTTP status codes in /api/scan_ean (404/400, 502 on send failure) and run it in the thread pool since it does blocking I/O --- app/main.py | 127 +++++++++++++++++++++++++--------------------- app/static/app.js | 46 +++++++++++++---- 2 files changed, 105 insertions(+), 68 deletions(-) diff --git a/app/main.py b/app/main.py index 56d9f46..f454e9f 100644 --- a/app/main.py +++ b/app/main.py @@ -174,23 +174,29 @@ async def detect_objects(payload: DetectionRequest): img = Image.open(BytesIO(img_bytes)).convert("RGB") img_np = np.array(img) - # Check if empty background calibration exists and compare + # Check if empty background calibration exists and compare. + # object_present: True/False when a background is calibrated, None when unknown. + # The frontend uses it to tell "scale empty" apart from "object there, but low confidence". + object_present = None bg_path = os.path.join(BASE_DIR, "dataset", "background.jpg") if os.path.exists(bg_path): # Use a slightly lower threshold (20) to ensure we capture any real item placed bbox = calculate_auto_crop_bbox(img_np, bg_path, threshold=20) - if bbox is None: + object_present = bbox is not None + if not object_present: # Scale is empty or no significant object placed return { "status": "success", - "predictions": [] + "predictions": [], + "object_present": False } - + predictions = detector.detect(img_np) - + return { "status": "success", - "predictions": predictions + "predictions": predictions, + "object_present": object_present } except Exception as e: return JSONResponse( @@ -841,70 +847,77 @@ async def save_articles(payload: SaveArticlesRequest): raise HTTPException(status_code=500, detail=f"Fehler beim Speichern der Artikeldatenbank: {str(e)}") # --- TCP SOCKET CLIENT SENDER --- -TCP_PORT = 9000 +# The cash register is the TCP server; we connect as a client. Configure via environment: +# POS_TCP_HOST (default 127.0.0.1), POS_TCP_PORT (default 9000), +# POS_TCP_TIMEOUT in seconds (default 0.5), +# POS_KEYBOARD_FALLBACK=0 disables keyboard emulation when TCP fails (default on). +POS_TCP_HOST = os.environ.get("POS_TCP_HOST", "127.0.0.1") +POS_TCP_PORT = int(os.environ.get("POS_TCP_PORT", "9000")) +POS_TCP_TIMEOUT = float(os.environ.get("POS_TCP_TIMEOUT", "0.5")) +POS_KEYBOARD_FALLBACK = os.environ.get("POS_KEYBOARD_FALLBACK", "1").lower() not in ("0", "false", "no", "off") def send_ean_via_tcp(ean_code: str): """ - Sends the EAN code to the cash register over TCP on port 9000. - Since the cash register is the server (listening on port 9000), we connect - as a client, transmit the barcode, and close the connection. + Sends the EAN code to the cash register over TCP. + Since the cash register is the server, we connect as a client, + transmit the barcode, and close the connection. """ message = (ean_code + "\r\n").encode('utf-8') - - print(f"TCP Client Mode: Connecting to cash register at 127.0.0.1:{TCP_PORT}...", flush=True) + + print(f"TCP Client Mode: Connecting to cash register at {POS_TCP_HOST}:{POS_TCP_PORT}...", flush=True) try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.settimeout(0.5) # Fast timeout - s.connect(('127.0.0.1', TCP_PORT)) + with socket.create_connection((POS_TCP_HOST, POS_TCP_PORT), timeout=POS_TCP_TIMEOUT) as s: s.sendall(message) print(f"TCP Client: Successfully sent EAN {ean_code} to cash register.", flush=True) return True except Exception as e: - print(f"TCP Client: Connection to cash register failed (is register listening on port {TCP_PORT}?): {e}", flush=True) + print(f"TCP Client: Connection to cash register failed (is register listening on {POS_TCP_HOST}:{POS_TCP_PORT}?): {e}", flush=True) return False +@app.get("/api/scanner_config") +async def get_scanner_config(): + return { + "host": POS_TCP_HOST, + "port": POS_TCP_PORT, + "timeout": POS_TCP_TIMEOUT, + "keyboard_fallback": POS_KEYBOARD_FALLBACK + } + @app.post("/api/scan_ean") -async def scan_ean_endpoint(payload: ScanRequest): - try: - class_name = payload.class_name - if not os.path.exists(ARTICLES_FILE): - raise HTTPException(status_code=404, detail="Artikeldatenbank existiert nicht.") - - with open(ARTICLES_FILE, "r") as f: - articles = json.load(f) - - if class_name not in articles: - raise HTTPException(status_code=404, detail=f"Klasse {class_name} nicht in Artikeldatenbank.") - - ean_code = articles[class_name].get("ean", "") - if not ean_code: - raise HTTPException(status_code=400, detail=f"Kein EAN-Code für {class_name} hinterlegt.") - - # 1. Send via TCP (Direct socket integration) - tcp_sent = send_ean_via_tcp(ean_code) - - # 2. Parallel keyboard emulation fallback on Windows - keyboard_sent = simulate_keyboard_type(ean_code) - - if tcp_sent or keyboard_sent: - methods = [] - if tcp_sent: - methods.append("TCP") - if keyboard_sent: - methods.append("Tastatur") - method_desc = " + ".join(methods) - - return { - "status": "success", - "class": class_name, - "name": articles[class_name].get("name", class_name), - "ean": ean_code, - "message": f"Barcode {ean_code} gesendet via {method_desc}!" - } - else: - raise HTTPException(status_code=500, detail="Übertragung fehlgeschlagen (TCP und Tastaturemulation gescheitert).") - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) +def scan_ean_endpoint(payload: ScanRequest): + # Plain def (not async): socket I/O and keyboard emulation block, so FastAPI runs this in a thread pool. + class_name = payload.class_name + if not os.path.exists(ARTICLES_FILE): + raise HTTPException(status_code=404, detail="Artikeldatenbank existiert nicht.") + + with open(ARTICLES_FILE, "r", encoding="utf-8") as f: + articles = json.load(f) + + if class_name not in articles: + raise HTTPException(status_code=404, detail=f"Klasse {class_name} nicht in Artikeldatenbank.") + + ean_code = articles[class_name].get("ean", "") + if not ean_code: + raise HTTPException(status_code=400, detail=f"Kein EAN-Code für {class_name} hinterlegt.") + + # 1. Send via TCP (primary path) + if send_ean_via_tcp(ean_code): + method_desc = f"TCP {POS_TCP_HOST}:{POS_TCP_PORT}" + # 2. Keyboard emulation only if TCP failed, so the register never receives the code twice + elif POS_KEYBOARD_FALLBACK and simulate_keyboard_type(ean_code): + method_desc = "Tastatur (TCP nicht erreichbar)" + else: + reason = "TCP und Tastaturemulation gescheitert" if POS_KEYBOARD_FALLBACK else "TCP gescheitert, Tastatur-Fallback deaktiviert" + raise HTTPException(status_code=502, detail=f"Übertragung an {POS_TCP_HOST}:{POS_TCP_PORT} fehlgeschlagen ({reason}).") + + return { + "status": "success", + "class": class_name, + "name": articles[class_name].get("name", class_name), + "ean": ean_code, + "method": method_desc, + "message": f"Barcode {ean_code} gesendet via {method_desc}!" + } # Mount static files (must be after api definitions) app.mount("/", StaticFiles(directory=static_path, html=True), name="static") diff --git a/app/static/app.js b/app/static/app.js index d45f0ac..1aa97b1 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -52,6 +52,12 @@ let autoScanEnabled = true; let stableClass = null; let stableCount = 0; const STABILITY_THRESHOLD = 4; // number of frames (approx 0.8s) +// Frames in a row that must look empty before the scan lock is released. +// With a calibrated background the backend reports object_present=false reliably; +// without one we only see "no confident prediction", so we wait longer. +const EMPTY_FRAMES_TO_RELEASE = 2; +const EMPTY_FRAMES_TO_RELEASE_NO_BG = 8; +let emptyFrameCount = 0; let lastScannedClass = null; let scanLockActive = false; let articlesDatabase = {}; @@ -355,7 +361,7 @@ async function runDetection() { if (data.status === "success") { drawDetections(data.predictions); updateQuickButtons(data.predictions); - processStabilityAndScan(data.predictions); + processStabilityAndScan(data.predictions, data.object_present); } } catch (err) { console.error("Detection error:", err); @@ -1048,7 +1054,7 @@ async function sendEanToPos(classKey) { if (response.ok && data.status === "success") { playScannerBeep(); - addScanLog(`✓ EAN ${article.ean} erfolgreich gesendet.`); + addScanLog(`✓ EAN ${article.ean} gesendet via ${data.method || "TCP"}.`); // Visual flash feedback on scanner card const scannerCard = document.querySelector(".scanner-card"); @@ -1057,7 +1063,7 @@ async function sendEanToPos(classKey) { setTimeout(() => scannerCard.classList.remove("scale-flash"), 400); } } else { - addScanLog(`❌ Fehler: ${data.message || "Tastaturemulation fehlgeschlagen"}`); + addScanLog(`❌ Fehler: ${data.detail || data.message || "Übertragung fehlgeschlagen"}`); } } catch (err) { console.error("Scan API error:", err); @@ -1066,7 +1072,8 @@ async function sendEanToPos(classKey) { } // Stability filter logic -async function processStabilityAndScan(predictions) { +// objectPresent: true/false from background subtraction, null/undefined if no background is calibrated. +async function processStabilityAndScan(predictions, objectPresent) { const autoScanToggle = document.getElementById("auto-scan-toggle"); const autoScan = autoScanToggle ? autoScanToggle.checked : true; @@ -1088,7 +1095,8 @@ async function processStabilityAndScan(predictions) { if (topPrediction && topPrediction.confidence >= STABLE_CONF_THRESHOLD) { const detectedClass = topPrediction.class; - + emptyFrameCount = 0; + if (stableClass !== detectedClass) { stableClass = detectedClass; stableCount = 1; @@ -1110,13 +1118,15 @@ async function processStabilityAndScan(predictions) { if (stableCount >= STABILITY_THRESHOLD) { if (lastScannedClass !== detectedClass && !scanLockActive) { + // Lock BEFORE awaiting the send: the next frame's detection can run while the + // request is in flight and would otherwise send the same EAN a second time. + lastScannedClass = detectedClass; + scanLockActive = true; 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%"; @@ -1128,20 +1138,34 @@ async function processStabilityAndScan(predictions) { } } } else { - const CLEAR_THRESHOLD = 0.15; - - if (!topPrediction || topPrediction.confidence < CLEAR_THRESHOLD) { + // The backend only returns predictions above its own threshold, so an empty list does NOT + // mean the scale is empty — the item may just be at a bad angle for one frame. + // Release the lock only after several frames in a row that really look empty. + const looksEmpty = objectPresent === false + || ((objectPresent === null || objectPresent === undefined) && !topPrediction); + const framesNeeded = objectPresent === false ? EMPTY_FRAMES_TO_RELEASE : EMPTY_FRAMES_TO_RELEASE_NO_BG; + emptyFrameCount = looksEmpty ? emptyFrameCount + 1 : 0; + + if (looksEmpty && emptyFrameCount >= framesNeeded) { 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 if (scanLockActive) { + // Item still on the scale (or not yet empty long enough): keep the lock + stableCount = 0; + if (progressWrapper) progressWrapper.style.display = "none"; + if (scannerIndicator) { + scannerIndicator.textContent = `Gesperrt (Waage leeren)`; + scannerIndicator.style.color = "var(--color-danger)"; + } } else { stableCount = Math.max(0, stableCount - 1); if (progressWrapper && progressFill) {