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
This commit is contained in:
DanielS
2026-10-19 20:48:09 +02:00
parent 5462784c1f
commit 1b8e51fbb4
2 changed files with 105 additions and 68 deletions

View File

@@ -174,23 +174,29 @@ async def detect_objects(payload: DetectionRequest):
img = Image.open(BytesIO(img_bytes)).convert("RGB") img = Image.open(BytesIO(img_bytes)).convert("RGB")
img_np = np.array(img) 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") bg_path = os.path.join(BASE_DIR, "dataset", "background.jpg")
if os.path.exists(bg_path): if os.path.exists(bg_path):
# Use a slightly lower threshold (20) to ensure we capture any real item placed # 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) 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 # Scale is empty or no significant object placed
return { return {
"status": "success", "status": "success",
"predictions": [] "predictions": [],
"object_present": False
} }
predictions = detector.detect(img_np) predictions = detector.detect(img_np)
return { return {
"status": "success", "status": "success",
"predictions": predictions "predictions": predictions,
"object_present": object_present
} }
except Exception as e: except Exception as e:
return JSONResponse( 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)}") raise HTTPException(status_code=500, detail=f"Fehler beim Speichern der Artikeldatenbank: {str(e)}")
# --- TCP SOCKET CLIENT SENDER --- # --- 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): def send_ean_via_tcp(ean_code: str):
""" """
Sends the EAN code to the cash register over TCP on port 9000. Sends the EAN code to the cash register over TCP.
Since the cash register is the server (listening on port 9000), we connect Since the cash register is the server, we connect as a client,
as a client, transmit the barcode, and close the connection. transmit the barcode, and close the connection.
""" """
message = (ean_code + "\r\n").encode('utf-8') 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: try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: with socket.create_connection((POS_TCP_HOST, POS_TCP_PORT), timeout=POS_TCP_TIMEOUT) as s:
s.settimeout(0.5) # Fast timeout
s.connect(('127.0.0.1', TCP_PORT))
s.sendall(message) s.sendall(message)
print(f"TCP Client: Successfully sent EAN {ean_code} to cash register.", flush=True) print(f"TCP Client: Successfully sent EAN {ean_code} to cash register.", flush=True)
return True return True
except Exception as e: 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 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") @app.post("/api/scan_ean")
async def scan_ean_endpoint(payload: ScanRequest): def scan_ean_endpoint(payload: ScanRequest):
try: # Plain def (not async): socket I/O and keyboard emulation block, so FastAPI runs this in a thread pool.
class_name = payload.class_name class_name = payload.class_name
if not os.path.exists(ARTICLES_FILE): if not os.path.exists(ARTICLES_FILE):
raise HTTPException(status_code=404, detail="Artikeldatenbank existiert nicht.") raise HTTPException(status_code=404, detail="Artikeldatenbank existiert nicht.")
with open(ARTICLES_FILE, "r") as f: with open(ARTICLES_FILE, "r", encoding="utf-8") as f:
articles = json.load(f) articles = json.load(f)
if class_name not in articles: if class_name not in articles:
raise HTTPException(status_code=404, detail=f"Klasse {class_name} nicht in Artikeldatenbank.") raise HTTPException(status_code=404, detail=f"Klasse {class_name} nicht in Artikeldatenbank.")
ean_code = articles[class_name].get("ean", "") ean_code = articles[class_name].get("ean", "")
if not ean_code: if not ean_code:
raise HTTPException(status_code=400, detail=f"Kein EAN-Code für {class_name} hinterlegt.") raise HTTPException(status_code=400, detail=f"Kein EAN-Code für {class_name} hinterlegt.")
# 1. Send via TCP (Direct socket integration) # 1. Send via TCP (primary path)
tcp_sent = send_ean_via_tcp(ean_code) if send_ean_via_tcp(ean_code):
method_desc = f"TCP {POS_TCP_HOST}:{POS_TCP_PORT}"
# 2. Parallel keyboard emulation fallback on Windows # 2. Keyboard emulation only if TCP failed, so the register never receives the code twice
keyboard_sent = simulate_keyboard_type(ean_code) elif POS_KEYBOARD_FALLBACK and simulate_keyboard_type(ean_code):
method_desc = "Tastatur (TCP nicht erreichbar)"
if tcp_sent or keyboard_sent: else:
methods = [] reason = "TCP und Tastaturemulation gescheitert" if POS_KEYBOARD_FALLBACK else "TCP gescheitert, Tastatur-Fallback deaktiviert"
if tcp_sent: raise HTTPException(status_code=502, detail=f"Übertragung an {POS_TCP_HOST}:{POS_TCP_PORT} fehlgeschlagen ({reason}).")
methods.append("TCP")
if keyboard_sent: return {
methods.append("Tastatur") "status": "success",
method_desc = " + ".join(methods) "class": class_name,
"name": articles[class_name].get("name", class_name),
return { "ean": ean_code,
"status": "success", "method": method_desc,
"class": class_name, "message": f"Barcode {ean_code} gesendet via {method_desc}!"
"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))
# Mount static files (must be after api definitions) # Mount static files (must be after api definitions)
app.mount("/", StaticFiles(directory=static_path, html=True), name="static") app.mount("/", StaticFiles(directory=static_path, html=True), name="static")

View File

@@ -52,6 +52,12 @@ let autoScanEnabled = true;
let stableClass = null; let stableClass = null;
let stableCount = 0; let stableCount = 0;
const STABILITY_THRESHOLD = 4; // number of frames (approx 0.8s) 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 lastScannedClass = null;
let scanLockActive = false; let scanLockActive = false;
let articlesDatabase = {}; let articlesDatabase = {};
@@ -355,7 +361,7 @@ async function runDetection() {
if (data.status === "success") { if (data.status === "success") {
drawDetections(data.predictions); drawDetections(data.predictions);
updateQuickButtons(data.predictions); updateQuickButtons(data.predictions);
processStabilityAndScan(data.predictions); processStabilityAndScan(data.predictions, data.object_present);
} }
} catch (err) { } catch (err) {
console.error("Detection error:", err); console.error("Detection error:", err);
@@ -1048,7 +1054,7 @@ async function sendEanToPos(classKey) {
if (response.ok && data.status === "success") { if (response.ok && data.status === "success") {
playScannerBeep(); playScannerBeep();
addScanLog(`✓ EAN ${article.ean} erfolgreich gesendet.`); addScanLog(`✓ EAN ${article.ean} gesendet via ${data.method || "TCP"}.`);
// Visual flash feedback on scanner card // Visual flash feedback on scanner card
const scannerCard = document.querySelector(".scanner-card"); const scannerCard = document.querySelector(".scanner-card");
@@ -1057,7 +1063,7 @@ async function sendEanToPos(classKey) {
setTimeout(() => scannerCard.classList.remove("scale-flash"), 400); setTimeout(() => scannerCard.classList.remove("scale-flash"), 400);
} }
} else { } else {
addScanLog(`❌ Fehler: ${data.message || "Tastaturemulation fehlgeschlagen"}`); addScanLog(`❌ Fehler: ${data.detail || data.message || "Übertragung fehlgeschlagen"}`);
} }
} catch (err) { } catch (err) {
console.error("Scan API error:", err); console.error("Scan API error:", err);
@@ -1066,7 +1072,8 @@ async function sendEanToPos(classKey) {
} }
// Stability filter logic // 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 autoScanToggle = document.getElementById("auto-scan-toggle");
const autoScan = autoScanToggle ? autoScanToggle.checked : true; const autoScan = autoScanToggle ? autoScanToggle.checked : true;
@@ -1088,7 +1095,8 @@ async function processStabilityAndScan(predictions) {
if (topPrediction && topPrediction.confidence >= STABLE_CONF_THRESHOLD) { if (topPrediction && topPrediction.confidence >= STABLE_CONF_THRESHOLD) {
const detectedClass = topPrediction.class; const detectedClass = topPrediction.class;
emptyFrameCount = 0;
if (stableClass !== detectedClass) { if (stableClass !== detectedClass) {
stableClass = detectedClass; stableClass = detectedClass;
stableCount = 1; stableCount = 1;
@@ -1110,13 +1118,15 @@ async function processStabilityAndScan(predictions) {
if (stableCount >= STABILITY_THRESHOLD) { if (stableCount >= STABILITY_THRESHOLD) {
if (lastScannedClass !== detectedClass && !scanLockActive) { 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) { if (autoScan) {
await sendEanToPos(detectedClass); await sendEanToPos(detectedClass);
} else { } else {
addScanLog(`[Auto-Scan aus] ${PRICING_DATABASE[detectedClass]?.name || detectedClass} stabil erkannt.`); addScanLog(`[Auto-Scan aus] ${PRICING_DATABASE[detectedClass]?.name || detectedClass} stabil erkannt.`);
} }
lastScannedClass = detectedClass;
scanLockActive = true;
} }
if (progressFill) progressFill.style.width = "0%"; if (progressFill) progressFill.style.width = "0%";
@@ -1128,20 +1138,34 @@ async function processStabilityAndScan(predictions) {
} }
} }
} else { } else {
const CLEAR_THRESHOLD = 0.15; // 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.
if (!topPrediction || topPrediction.confidence < CLEAR_THRESHOLD) { // 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; stableClass = null;
stableCount = 0; stableCount = 0;
scanLockActive = false; scanLockActive = false;
lastScannedClass = null; lastScannedClass = null;
if (progressWrapper) progressWrapper.style.display = "none"; if (progressWrapper) progressWrapper.style.display = "none";
if (progressFill) progressFill.style.width = "0%"; if (progressFill) progressFill.style.width = "0%";
if (scannerIndicator) { if (scannerIndicator) {
scannerIndicator.textContent = "Bereit (Warte auf Objekt)"; scannerIndicator.textContent = "Bereit (Warte auf Objekt)";
scannerIndicator.style.color = "var(--color-success)"; 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 { } else {
stableCount = Math.max(0, stableCount - 1); stableCount = Math.max(0, stableCount - 1);
if (progressWrapper && progressFill) { if (progressWrapper && progressFill) {