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 = 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")
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user