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:
127
app/main.py
127
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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user