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_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")

View File

@@ -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) {