diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,50 +1,2466 @@ -from fastapi import FastAPI, HTTPException -from pydantic import BaseModel -import threading, queue, time -from seleniumbase import SB +import io, json, time, ctypes, cv2, os, serial.tools.list_ports, io, glob, threading, numpy as np, queue as _q, hashlib, base64, csv -app = FastAPI() +from flask import Flask, jsonify, redirect, render_template, request, Response, send_from_directory, url_for, make_response +from PIL import Image, ImageDraw, ImageFont, Image as _Im +from datetime import datetime +from contextlib import suppress -class URLRequest(BaseModel): - url: str +from modules.signature_db import finalize_completed, bind_signatures_to_session +from modules.signature_capture import VehicleSignatureCapture, CaptureConfig +from modules.camera import CameraManager +from modules.detect import DetectorManager +from modules.serial_reader import SerialWeightReader +from modules.usb_escpos_direct import usb_direct_print_image +from modules.utils import db_execute, db_query, draw_roi, ensure_db, log_event, now_ts, safe_int, subscribe_logs, unsubscribe_logs, ensure_image_tables_and_columns, resolve_data_file, ensure_staged_table +from modules.webrtc import WebRTCManager +from modules.winagent import WinAgent -url_queue = queue.Queue() -sb_ref = {"driver": None} +try: + import win32print, win32ui +except Exception: + win32print = None + win32ui = None +try: + from ctypes import wintypes +except Exception: + wintypes = None + +try: + from reid.model_setup import ensure_runtime_and_models + from modules.db_gateway import ensure_views_and_indexes + ensure_views_and_indexes() + ensure_runtime_and_models() +except Exception as e: + print("[setup] ERROR:", e) + raise + +CONFIG_PATH = resolve_data_file('config.json') + +STREAM_JPEG_QUALITY = 95 +STREAM_FRAME_INTERVAL = 0.025 +STREAM_DOWNSCALE_MAX_WIDTH = 960 + +DISPLAY_STATE = {"status":"idle","message":"พร้อมใช้งาน","queue":[],"current":None} +PIPELINE_RUNNING = {"value": False} + +THREADS = [] +RUNTIME_PRINT_OVERRIDES = {} +LAST_PRINT_OVERRIDES = RUNTIME_PRINT_OVERRIDES + +APP_PORT = int(os.environ.get("APP_PORT", "8080")) +APP_HOST = os.environ.get("APP_HOST", "0.0.0.0") + +CUSTOMER_LABEL = { + "idle": "รอการชั่ง", + "in_queue": "เข้าคิวแล้ว", + "in_progress": "กำลังชั่ง", + "waiting_stable": "น้ำหนักยังไม่คงที่", + "waiting_leave": "รอรถออก", + "matching": "กำลังจับคู่", + "matched": "จับคู่สำเร็จ", + "printing": "กำลังพิมพ์", + "printed": "พิมพ์แล้ว", + "completed": "ปิดงาน", + "error": "ผิดพลาด", + "paused": "พักชั่วคราว", + "stopped": "หยุดทำงาน", +} + +DEFAULT_CONFIG = { + "lan_port": APP_PORT, + "allow_online": False, + "customer_display_title": "", + "ocr": { + "source": "serial", + "stable_hsv_low": [0, 100, 100], + "stable_hsv_high": [10, 255, 255], + "min_weight": 100, + "settle_seconds": 4, + "show_roi": True, + "roi": [100, 100, 400, 200], + "window_title": "", + "ipcam_rtsp": "", + "serial": {"mode":"ip","ip_host":"192.168.10.226","ip_port":7000,"port":"COM4","baudrate":1200,"bytesize":7,"parity":"E","stopbits":1,"timeout":1.0,"pattern":"(\\d+)"} + }, + "cctv": { + "enabled": True, + "rtsp": "", + "show_roi": True, + "detect_people": False, + "detect_vehicles": True, + "roi": [112, 94, 768, 330] + }, + "cctv_ocr": { + "rtsp":"", + "show_roi": True, + "roi_digits": [477,25,61,32], + "roi_evidence": [76,0,720,540] + + }, + "weighing": { + "peopleleave_weighagain": True, + "wait_out_seconds": 4, + "unmatched_timeout_minutes": 40, + "wait_out_minutes": 1, + "min_weight": 150, + "high_weight": 30000, + "settle_seconds": 3, + "stable_delta_kg": 3, + }, + "printing": { + "ticket_mode": "detailed", + "print_width": 576, + "paper_width_mm": 80, + "dpi_roll": 203, + "scale_percent": 100, + "font_scale_percent": 150, + "spacing_scale": 1, + "threshold": 10, + "max_cmd_bytes": 3600, + "invert": False, + "dither": True, + "show_time": True, + "header_name": "ลานพงศ์ธวัช", + "header_address": "66 หมู่ 14 ตำบลขนุน อำเภอกันทรลักษ์ จังหวัดศรีสะเกษ", + "header_phone": "0981755592", + "print_evidence_images": True, + "print_in_out_images": False, + "print_weight_images": True, + "print_blank_bottom": False, + "auto_print_when_complete": True, + "sequence": [ + "header", + "in_vehicle_image", + "in_weight_image", + "out_vehicle_image", + "out_weight_image", + "in_weight_text", + "out_weight_text", + "blank_price_area" + ], + "driver": "escpos_net", + "escpos_net_host": "192.168.10.131", + "escpos_net_port": 9100, + "windows_printer_name": "", + "paper": {"type":"roll","width_mm":80, + "escpos_usb": {"vid": "0x0471", "pid": "0x0055"}} + }, + "queue": { + "min_complete_seconds": 8, + "recent_done_weight_epsilon_kg": 0.5, + "recent_done_guard_seconds": 10, + "recent_done_guard_enabled": False, + "recent_active_guard_enabled": False, + "allow_noop_actions": False, + "duplicate_vehicle_prompt": True + }, + "matching": { + "threshold_multi": 0.75, + "threshold": 0.70, + "ttl_seconds": 1800, + "in_greater_than_out_margin": 1.0, + "enabled": True, + "topdown": True, + "signature": { + "roi_source": "cctv", + "resize_to": [256, 256], + "color_hist_bins": 32, + "use_orb": True, + "use_hu_moments": True, + "weights": {"cnn": 1.0, "color": 0.3, "ahash": 0.1, "flip_invariant": False} + }, + "embedding": { + "backend": "onnx", + "model_path": "data/models/mobilenetv3_small_224.onnx", + "input_size": [224,224], + "normalize": "imagenet" + }, + "hysteresis": {"require_leave_frames": 10, "cooldown_seconds": 5} + }, + "yolo": { + "enable": True, + "models_dir": "models", + "car_pt_url": "https://1drv.ms/u/c/db9f312c63633e8a/ERwCmIOvpLJKjKHNxhIr3YwBbZ4p-nlGAqwma4bsCBoeHw?e=oXFZh0", + "car_pt_path": "models/car.pt", + "conf": 0.25, + "compat_threshold": 0.40, + "weights": { "car": 0.6, "part": 0.4 } + }, + "reid": { "min_cnn": 0.55, "cnn": 1.0, "color": 0.25, "ahash": 0.15 }, + "queue": { "abs_weight_tol": 50.0, "rel_weight_tol": 0.02 } +} + +app = Flask(__name__, template_folder='templates', static_folder='static') +app.config['TEMPLATES_AUTO_RELOAD'] = True + +os.makedirs("data", exist_ok=True) +os.makedirs("data/images", exist_ok=True) + +ensure_db() +ensure_image_tables_and_columns() + +def _deep_merge(dst, src): + for k, v in (src or {}).items(): + if isinstance(v, dict): + dst.setdefault(k, {}) + _deep_merge(dst[k], v) + else: + if k not in dst: + dst[k] = v + return dst + +if not os.path.exists(CONFIG_PATH): + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(DEFAULT_CONFIG, f, ensure_ascii=False, indent=2) +with open(CONFIG_PATH, "r", encoding="utf-8") as f: + CONFIG = json.load(f) + _deep_merge(CONFIG, DEFAULT_CONFIG) + +camera_mgr = CameraManager(CONFIG) +detector_mgr = DetectorManager(CONFIG) +win_agent = WinAgent(CONFIG) +webrtc_mgr = WebRTCManager() +serial_mgr = SerialWeightReader(CONFIG) + +def pipeline_loop(): + _t = __import__('time') + def _now(): return _t.time() + def _sleep(s): _t.sleep(s) + + def _state(s, message=None, *, force=False): + prev_status = DISPLAY_STATE.get("status") + prev_msg = DISPLAY_STATE.get("message") + if prev_status != s: + update_state(s) + if (message is not None) and (prev_msg != message): + DISPLAY_STATE.update({"message": message}) + if not hasattr(_state, "_last"): + _state._last = (None, None) + cur = (s, message) + if cur != _state._last or force: + try: log_event("state", s if not message else f"{s}: {message}") + except Exception: pass + _state._last = cur + + def _safe_crop(img, x, y, w, h): + try: + H, W = img.shape[:2] + x=max(0,x); y=max(0,y); w=max(0,w); h=max(0,h) + x2=min(W, x+w); y2=min(H, y+h) + if w<=0 or h<=0 or x>=x2 or y>=y2: return None + return img[y:y2, x:x2].copy() + except Exception: + return None + + def _to_b64(img): + if img is None: return None + ok, buf = cv2.imencode('.jpg', img, [int(cv2.IMWRITE_JPEG_QUALITY), int(STREAM_JPEG_QUALITY)]) + return base64.b64encode(buf).decode('ascii') if ok else None + + def _reset_to_idle(): + nonlocal await_leave, session_open, prev_above, stable_anchor, stable_since + await_leave = False + session_open = False + prev_above = False + stable_anchor = None + stable_since = None + _state('idle', 'รอการชั่ง') + + cap = VehicleSignatureCapture(CaptureConfig()) + _state("idle", "Pipeline started") + + await_leave = False + session_open = False + prev_above = False + stable_anchor = None + stable_since = None + people_counter = 0 + + while PIPELINE_RUNNING["value"]: + try: + ocr_cfg = CONFIG.get("ocr", {}) or {} + cctv_cfg = CONFIG.get("cctv", {}) or {} + weigh_cfg = CONFIG.get("weighing", {}) or {} + + settle_secs = float(CONFIG["weighing"]["settle_seconds"]) + min_w = float(CONFIG["weighing"]["min_weight"]) + max_w = float(CONFIG["weighing"]["high_weight"]) + tol_kg = float(weigh_cfg.get("stable_delta_kg")) + zero_th = 50 + + cctv_frame = camera_mgr.grab_frame() + if ocr_cfg.get("source") == "window": + scale_frame = win_agent.capture_window_frame() + else: + scale_frame = (_grab_cctv_ocr_once() if callable(globals().get("_grab_cctv_ocr_once")) + else camera_mgr.grab_frame_from_ocr_source()) + + cctv_raw = (cctv_frame.copy() if cctv_frame is not None else None) + scale_raw = (scale_frame.copy() if scale_frame is not None else None) + if cctv_frame is not None and cctv_cfg.get("show_roi"): + x, y, w, h = tuple(cctv_cfg.get("roi", (0,0,0,0))) + try: + x,y,w,h = _map_roi_to_full(cctv_frame.shape[1], cctv_frame.shape[0], (x,y,w,h), STREAM_DOWNSCALE_MAX_WIDTH) + draw_roi(cctv_frame, (x,y,w,h), color=(0,255,0)) + except Exception: pass + if scale_frame is not None and ocr_cfg.get("show_roi"): + rx, ry, rw, rh = tuple(ocr_cfg.get("roi", (0,0,0,0))) + try: draw_roi(scale_frame, (rx,ry,rw,rh), color=(255,0,0)) + except Exception: pass + + det = {"vehicle_on_border": False, "vehicle_present": False, "vehicle_boxes": []} + if (cctv_frame is not None) and cctv_cfg.get("enabled"): + _rx, _ry, _rw, _rh = tuple(cctv_cfg.get("roi", (0,0,0,0))) + try: + _rx, _ry, _rw, _rh = _map_roi_to_full(cctv_frame.shape[1], cctv_frame.shape[0], (_rx,_ry,_rw,_rh), STREAM_DOWNSCALE_MAX_WIDTH) + except Exception: + pass + _mx, _my = 2, 2 + _adj_roi = (_rx+_mx, _ry+_my, max(0,_rw-2*_mx), max(0,_rh-2*_my)) + try: det = detector_mgr.process_frame(cctv_frame, _adj_roi) + except Exception: pass + + if isinstance(det.get("people_delta"), (int,float)): + people_counter = max(0, people_counter + int(det.get("people_delta") or 0)) + + weight_value = None + sval = serial_mgr.get_latest() + if sval is not None: + try: weight_value = float(sval) + except Exception: weight_value = None + + if weight_value is None: + _state('error', 'เครื่องชั่งผิดพลาด') + continue + + if await_leave: + if (weight_value is None) or (float(weight_value) <= zero_th): + _reset_to_idle() + else: + _state('waiting_leave', 'กำลังรอกลับ 0') + _sleep(0.03) + continue + + try: + _wm = int((CONFIG.get("weighing", {}).get("unmatched_timeout_minutes"))) + except Exception: + _wm = 60 + + try: + db_execute( + "UPDATE sessions SET status=?, expired_at=CURRENT_TIMESTAMP " + "WHERE LOWER(TRIM(status))='in_queue' AND " + "datetime(substr(replace(replace(replace(COALESCE(in_time, updated_at, ''), 'T',' '),'Z',''),'/','-'),1,19)) " + "<= datetime('now','localtime', ?)", + ('expired', f'-{_wm} minutes') + ) + except Exception: + pass + + if (weight_value is None) or (float(weight_value) <= min_w): + _reset_to_idle() + _sleep(0.03) + continue + + above = float(weight_value) >= float(min_w) + if above and not prev_above: + session_open = True + stable_anchor = None + stable_since = None + _state('waiting_stable', 'กำลังรอนิ่ง') + elif (not above) and prev_above: + _reset_to_idle() + + prev_above = above + if not session_open: + _sleep(0.02) + continue + + if float(weight_value) > float(max_w): + _state("waiting_leave", f"ยกเลิก: น้ำหนักเกิน {max_w}") + await_leave = True + try: db_execute("DELETE FROM staged_capture") + except Exception: pass + _sleep(0.15) + continue + + nowt = _now() + if stable_anchor is None: + stable_anchor = float(weight_value) + stable_since = nowt + _state("waiting_stable", "กำลังรอนิ่ง") + _sleep(0.02) + continue + + if abs(float(weight_value) - float(stable_anchor)) <= tol_kg: + if (nowt - float(stable_since)) < float(settle_secs): + _state("waiting_stable", "กำลังรอนิ่ง") + _sleep(0.02) + continue + else: + stable_anchor = float(weight_value) + stable_since = nowt + _state("waiting_stable", "กำลังรอนิ่ง") + _sleep(0.02) + continue + + _state("in_progress", "จับภาพ/ลายเซ็น") + + if det.get("vehicle_boxes"): + rx, ry, rw, rh = map(int, (cctv_cfg.get("roi", (0,0,0,0)))) + vx, vy, vw, vh = rx, ry, rw, rh + else: + vx, vy, vw, vh = map(int, (cctv_cfg.get("roi", (0,0,0,0)))) + + weight_src_sel = (CONFIG.get("printing", {}) or {}).get("weight_in_image_from") or (CONFIG.get("printing", {}) or {}).get("weight_image_from", "cctv_ocr_evidence") + if weight_src_sel == "cctv_ocr_evidence": + wx, wy, ww, wh = map(int, (CONFIG.get("cctv_ocr", {}) or {}).get("roi_evidence", (0,0,0,0))) + else: + wx, wy, ww, wh = map(int, (CONFIG.get("cctv_ocr", {}) or {}).get("roi_digits", (0,0,0,0))) + + vx,vy,vw,vh = _map_roi_to_full(cctv_raw.shape[1], cctv_raw.shape[0], (vx,vy,vw,vh), STREAM_DOWNSCALE_MAX_WIDTH) + img_vehicle = _safe_crop(cctv_raw, vx, vy, vw, vh) if cctv_raw is not None else None + wx,wy,ww,wh = _map_roi_to_full(scale_raw.shape[1], scale_raw.shape[0], (wx,wy,ww,wh), STREAM_DOWNSCALE_MAX_WIDTH) + img_weight = _safe_crop(scale_raw, wx, wy, ww, wh) if scale_raw is not None else None + + try: + v_b64 = _to_b64(img_vehicle) + w_b64 = _to_b64(img_weight) + ensure_staged_table() + db_execute("DELETE FROM staged_capture") + db_execute( + "INSERT INTO staged_capture(ts,vehicle_b64,weight_b64,vx,vy,vw,vh,wx,wy,ww,wh) VALUES(?,?,?,?,?,?,?,?,?,?,?)", + (now_ts(), v_b64, w_b64, int(vx),int(vy),int(vw),int(vh), int(wx),int(wy),int(ww),int(wh)) + ) + except Exception as _e: + log_event("warn", f"staged_capture failed: {str(_e)}") + + + qres = None + try: + qres = cap.capture_stable( + session_id=None, + full_frame=cctv_raw, bbox=(vx,vy,vw,vh), + stable_weight=float(weight_value), cfg=CONFIG, + extra={"vxvywh":[vx,vy,vw,vh]} + ) + except Exception as _e: + log_event("error", f"capture_stable failed: {str(_e)}") + qres = {"action":"error","error":str(_e)} + + os.makedirs("data/images", exist_ok=True) + action = str((qres or {}).get('action') or '').strip().lower() + qid = int(qres.get('queue_id')) if (qres and qres.get('queue_id')) else None + + try: + db_execute("ALTER TABLE sessions ADD COLUMN queue_id INTEGER") + except Exception: + pass + try: + if qid is not None: + db_execute("UPDATE sessions SET queue_id=? WHERE id=?", (qid, session_id)) + except Exception: + pass + + log_event('state', f'decision: action={action or ""} qid={qid or "-"}') + + if action == 'enqueued': + session_id = db_execute( + "INSERT INTO sessions(status,in_time,people_out_count,in_weight) VALUES(?,?,?,?)", + ("in_queue", now_ts(), people_counter, float(weight_value)), + return_id=True + ) + + v_b64 = _to_b64(img_vehicle) + w_b64 = _to_b64(img_weight) + + if not v_b64 or not w_b64: + rows_sc = db_query("SELECT vehicle_b64, weight_b64 FROM staged_capture ORDER BY id DESC LIMIT 1") + if rows_sc: + v_b64 = v_b64 or rows_sc[0].get('vehicle_b64') + w_b64 = w_b64 or rows_sc[0].get('weight_b64') + if v_b64: + with open(f"data/images/evidence_{session_id}_in_vehicle.jpg","wb") as f: + f.write(base64.b64decode(v_b64)) + try: db_execute("UPDATE sessions SET in_preview_vehicle=? WHERE id=?", + (f"evidence_{session_id}_in_vehicle.jpg", session_id)) + except Exception: pass + if w_b64: + with open(f"data/images/evidence_{session_id}_in_weight.jpg","wb") as f: + f.write(base64.b64decode(w_b64)) + try: db_execute("UPDATE sessions SET in_preview_weight=? WHERE id=?", + (f"evidence_{session_id}_in_weight.jpg", session_id)) + except Exception: pass + try: bind_signatures_to_session(session_id, minutes=2) + except Exception: pass + await_leave = True + + elif action == "completed": + out_sig_id = None + for k in ("sig_id", "signature_id", "out_sig_id"): + if qres and qres.get(k) is not None: + try: out_sig_id = int(qres.get(k)); break + except Exception: pass + try: + entry_sess_id = finalize_completed( + qid=int(qid), + out_sig_id=out_sig_id, + out_weight=float(weight_value), + img_vehicle=img_vehicle, + img_weight=img_weight, + now_ts_str=now_ts() + ) + rows = db_query("SELECT * FROM sessions WHERE id = ?", (entry_sess_id,)) + sess = rows[0] if rows else None + img = render_receipt_image(CONFIG, overrides=_get_print_overrides(), session=sess, for_print=True) + params = load_print_params(CONFIG) + _mode = str(((CONFIG.get("printing", {}) or {}).get("ticket_mode") or "detailed")).lower() + if _mode in ("detailed","both"): + w, h = img.size + usb_direct_print_image( + img, + vid=params["vid"], + pid=params["pid"], + print_width=params["pw"], + threshold=params["th"], + invert=params["inv"], + dither=params["dith"], + max_cmd_bytes=params["chunk"], + driver=params["driver"], + escpos_net_host=params["host"], + escpos_net_port=params["port"], + ) + db_execute("UPDATE sessions SET print_status=? WHERE id=?", ("printed", entry_sess_id)) + log_event("print", f"Auto USB-direct #{entry_sess_id}") + except Exception as e: + log_event("error", f"complete failed: {e}") + await_leave = False + session_open = False + _state("idle", "รอน้ำหนัก ≥ ขั้นต่ำ") + + try: db_execute("DELETE FROM staged_capture") + except Exception: pass + + if action in ('enqueued','completed'): + await_leave = True + _state('waiting_leave', 'กำลังรอกลับ 0') + else: + await_leave = False + session_open = False + _state('idle', 'รอน้ำหนัก ≥ ขั้นต่ำ') + + _sleep(0.05) + continue + + except Exception as e: + try: log_event("error", f"pipeline_loop error: {str(e)}") + except Exception: pass + _sleep(0.2) + +def compose_session_view(primary_id: int): + rows = db_query("SELECT * FROM sessions WHERE id=?", (primary_id,)) + if not rows: + return None + s = dict(rows[0]) + pid = s.get('paired_session_id') + if not pid: + return s + prows = db_query("SELECT * FROM sessions WHERE id=?", (int(pid),)) + if not prows: + return s + t = dict(prows[0]) + if t.get('out_time') or (t.get('out_weight') is not None): + s['out_time'] = t.get('out_time') + s['out_weight'] = t.get('out_weight') + s['out_preview_image'] = f"evidence_{int(pid)}_out_vehicle.jpg" + s['out_preview_weight'] = f"evidence_{int(pid)}_out_weight.jpg" + else: + s['out_time'] = t.get('in_time') + s['out_weight'] = t.get('in_weight') + s['out_preview_image'] = f"evidence_{int(pid)}_in_vehicle.jpg" + s['out_preview_weight'] = f"evidence_{int(pid)}_in_weight.jpg" + return s + +def _get_print_overrides(): + base = dict(CONFIG.get("printing", {}) or {}) + try: + for k, v in (RUNTIME_PRINT_OVERRIDES or {}).items(): + base[k] = v + except Exception: + pass + return base + +def _get_print_param(name, default=None): + try: + v = (RUNTIME_PRINT_OVERRIDES or {}).get(name, None) + if v not in (None, "", []): + return v + except Exception: + pass + try: + return (CONFIG.get("printing", {}) or {}).get(name, default) + except Exception: + return default + +def update_state(new_status: str, message: str | None = None): + s = (new_status or "idle").lower().strip() + msg = message if message is not None else CUSTOMER_LABEL.get(s, s) + DISPLAY_STATE.update({"status": s, "message": msg}) + +def _map_roi_to_full(frame_w, frame_h, roi, maxw): + try: + x, y, w, h = roi + except Exception: + x = y = w = h = 0 + try: + fx = float(w) + fy = float(h) + if 0 < fx <= 1.0 and 0 < fy <= 1.0 and 0 <= float(x) <= 1.0 and 0 <= float(y) <= 1.0: + return int(round(float(x)*frame_w)), int(round(float(y)*frame_h)), int(round(fx*frame_w)), int(round(fy*frame_h)) + except Exception: + pass + prev_w = frame_w + prev_h = frame_h + if frame_w > int(maxw or 0): + ratio = float(maxw) / float(frame_w) + prev_w = int(frame_w * ratio) + prev_h = int(frame_h * ratio) + if prev_w <= 0 or prev_h <= 0: + return int(x), int(y), int(w), int(h) + if prev_w == frame_w and prev_h == frame_h: + return int(x), int(y), int(w), int(h) + sx = frame_w / float(prev_w) + sy = frame_h / float(prev_h) + return int(round(x*sx)), int(round(y*sy)), int(round(w*sx)), int(round(h*sy)) + +def _resolve_preview_canvas(config, overrides=None): + overrides = overrides or {} + kind = overrides.get("paper_kind") + if isinstance(kind, str) and ":" in kind: + mode, val = kind.split(":", 1) + mode = mode.strip().lower(); val = val.strip().upper() + if mode == "roll": + try: + w_mm = int(val) + except Exception: + w_mm = 80 + base_w = 550 + W = int(base_w * (w_mm/80.0)) + H = int(W * (1200/550.0)) + return W, H + elif mode == "sheet": + mm = { + "A4": (794, 1123), + "A5": (559, 794), + "LETTER": (816, 1056), + "LEGAL": (816, 1344), + "B5": (693, 984), + } + return mm.get(val, (794, 1123)) + + try: + if os.name == "nt": + hdc = win32ui.CreateDC() + printer_name = config.get("printing",{}).get("windows_printer_name") or win32print.GetDefaultPrinter() + hdc.CreatePrinterDC(printer_name) + PHYS_W = hdc.GetDeviceCaps(110) + PHYS_H = hdc.GetDeviceCaps(111) + if PHYS_W and PHYS_H: + maxw = 900 + scale = min(1.0, maxw / PHYS_W) + return max(300, int(PHYS_W*scale)), max(400, int(PHYS_H*scale)) + except Exception: + pass + + try: + p = config.get("printing",{}).get("paper",{}) + ptype = (p.get("type") or "roll").lower() if isinstance(p, dict) else "roll" + except Exception: + ptype = "roll" + + if ptype == "sheet": + return 794, 1123 + + try: + w_mm = int(config.get("printing",{}).get("paper_width_mm", 80)) + W = int(550 * (w_mm/80.0)) + return W, int(W * (1200/550.0)) + except Exception: + return 550, 1200 + +def render_receipt_image(config, overrides=None, session=None, for_print=False): + overrides = overrides or {} + pcfg = dict(config.get("printing", {})) + + try: + _fsc = overrides.get("font_scale_percent", None) + except Exception: + _fsc = None + if _fsc is None: + _fsc = pcfg.get("font_scale_percent") + + try: + FONT_SCALE = max(0.5, float(_fsc) / 100.0) + except Exception: + FONT_SCALE = 1.0 + + for k in ["header_name","header_address","header_phone", + "print_in_out_images","print_weight_images","print_blank_bottom", + "font_path"]: + if k in overrides and overrides[k] is not None: + pcfg[k] = overrides[k] + + if for_print: + try: + p = pcfg.get("paper", {}) + ptype = (p.get("type") or "roll").lower() if isinstance(p, dict) else "roll" + except Exception: + ptype = "roll" + if ptype == "sheet": + dpi = int(pcfg.get("dpi_sheet", 300)) + W, H = int(8.27 * dpi), int(11.69 * dpi) + else: + dpi = int(pcfg.get("dpi_roll", 203)) + W, H = int(3.15 * dpi), int(8.0 * dpi) + else: + W, H = _resolve_preview_canvas(config, overrides) + + candidates = [os.path.join('data','font','Sarabun-Regular.ttf'), os.path.join('data','font','NotoSansThai-Regular.ttf')] + custom = str(pcfg.get("font_path") or "").strip() + if custom: candidates.append(custom) + candidates += [r"C:\Windows\Fonts\LeelawUI.ttf", r"C:\Windows\Fonts\leelawui.ttf", + r"C:\Windows\Fonts\tahoma.ttf", r"C:\Windows\Fonts\THSarabunNew.ttf", + r"C:\Windows\Fonts\angsau.ttf", r"C:\Windows\Fonts\angsanaupc.ttf", + "/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf", + "/usr/share/fonts/truetype/noto/NotoSansThaiUI-Regular.ttf", + "/usr/share/fonts/truetype/noto/NotoSansThaiLooped-Regular.ttf"] + font_path = next((p for p in candidates if p and os.path.exists(p)), None) + content_h = int(H * 2.5) + content = Image.new("L", (W, content_h), 255) + d = ImageDraw.Draw(content) + no_top_margin = bool((overrides or {}).get("no_top_margin") or pcfg.get("no_top_margin", False)) + m = 0 if no_top_margin else max(8, int(0.02 * W)) + y = m + + def text_bbox(s, f): + try: + return d.textbbox((0,0), s, font=f) + except Exception: + w = d.textlength(s, font=f); return (0,0,int(w),f.size) + + def load_font(size): + try: + size = max(1, int(round(size * FONT_SCALE))) + if font_path: + return ImageFont.truetype(font_path, size=size) + except Exception: + pass + return ImageFont.load_default() + + def center_text(s, size=24, sep=8, bold=False): + nonlocal y + sep = int(round(sep * FONT_SCALE)) + f = load_font(size) + box = text_bbox(s, f); w = box[2]-box[0]; h = box[3]-box[1] + x = (W - w)//2 + if bold: + for dx,dy in [(0,0),(1,0),(0,1),(-1,0),(0,-1)]: + d.text((x+dx, y+dy), s, fill=0, font=f) + else: + d.text((x, y), s, fill=0, font=f) + y += h + sep + + def _wrap_text_by_width(s, font, max_w_px): + lines = [] + buf = "" + for ch in (s or ""): + test = buf + ch + if d.textlength(test, font=font) <= max_w_px: + buf = test + else: + if buf: + lines.append(buf) + buf = ch + if buf: + lines.append(buf) + + flattened = [] + for ln in lines: + flattened.extend((ln or "").split("\n")) + return [ln for ln in flattened if ln is not None] + + def center_wrap_text(s, size=24, sep=8, bold=False, max_width_ratio=0.92, line_sep_scale=0.6): + f = load_font(size) + max_w_px = int(W * max_width_ratio) + lines = [] + for raw_line in (s or "").split("\n"): + + if d.textlength(raw_line, font=f) <= max_w_px: + lines.append(raw_line) + else: + lines.extend(_wrap_text_by_width(raw_line, f, max_w_px)) + if not lines: + return + + for i, ln in enumerate(lines): + if i == len(lines) - 1: + center_text(ln, size=size, sep=sep, bold=bold) + else: + tight_sep = max(1, int(round(sep * line_sep_scale))) + center_text(ln, size=size, sep=tight_sep, bold=bold) + + def draw_frame(x, ytop, w, h, patterns, placeholder_text): + d.rounded_rectangle((x, ytop, x+w, ytop+h), radius=int(0.02*W), outline=0, width=max(1,int(W*0.002))) + files = [] + for pat in patterns: + files += glob.glob(pat) + placed = False + if files: + try: + fp = files[-1] + im = _Im.open(fp).convert("L") + scale = min((w-16) / max(1, im.width), (h-16) / max(1, im.height)) + nw, nh = max(1, int(im.width*scale)), max(1, int(im.height*scale)) + im = im.resize((nw, nh)) + px = x + (w - nw)//2 + py = ytop + (h - nh)//2 + content.paste(im, (px, py)) + placed = True + except Exception: + placed = False + if not placed and placeholder_text: + fph = load_font(int(0.04*W)) + bw = text_bbox(placeholder_text, fph)[2] + d.text((x + (w - bw)//2, ytop + h//2 - int(0.02*W)), placeholder_text, fill=0, font=fph) + if session: + try: + in_w = session.get("in_weight") if isinstance(session, dict) else session["in_weight"] + except Exception: + pass + try: + if isinstance(session, dict): + out_w = session.get("out_weight", session.get("weight_out")) + else: + try: + out_w = session["out_weight"] + except Exception: + out_w = session["weight_out"] + except Exception: + pass + + try: + sid = overrides.get("session_id") if isinstance(overrides, dict) else None + if sid is None and session is not None: + try: + sid = session.get("id") if isinstance(session, dict) else session["id"] + except Exception: + sid = None + if sid is not None and (in_w is None or out_w is None): + row = db_query("SELECT in_weight, out_weight FROM sessions WHERE id = ?", (sid,)) + if row: + if in_w is None: + in_w = row[0]["in_weight"] + if out_w is None: + out_w = row[0]["out_weight"] + except Exception: + pass + + name = (pcfg.get("header_name") or overrides.get("header_name") or "").strip() or "xxx" + addr = (pcfg.get("header_address") or overrides.get("header_address") or "").strip() or "xxx" + phone = (pcfg.get("header_phone") or overrides.get("header_phone") or "").strip() or "xxx" + + center_text(name, int(0.07*W), sep=int(0.015*H), bold=True) + center_wrap_text(addr, int(0.04*W), sep=int(0.010*H), bold=False, max_width_ratio=0.92, line_sep_scale=0.65) + center_text(phone, int(0.04*W), sep=int(0.020*H), bold=False) + + if bool(pcfg.get("print_in_out_images", False)) or bool(pcfg.get("print_weight_images", False)): + center_text("รูปหลักฐาน", int(0.045*W), sep=int(0.015*H), bold=True) + g = int(0.02*W) + col_w = int((W - m*2 - g) / 2) + row_h_vehicle = int(0.20*H) + row_h_weight = int(0.18*H) + + sid = None + try: + sid = session.get("id") if isinstance(session, dict) else session["id"] + except Exception: + sid = None + if sid is None: + try: + sid = int(overrides.get("session_id")) + except Exception: + sid = None + + def session_image_candidates(kind): + cands = [] + try: + if isinstance(session, dict): + if kind == 'in_vehicle' and session.get('in_preview_image'): + cands.append(os.path.join('data','images', session['in_preview_image'])) + if kind == 'in_weight' and session.get('in_preview_weight'): + cands.append(os.path.join('data','images', session['in_preview_weight'])) + if kind == 'out_vehicle' and session.get('out_preview_image'): + cands.append(os.path.join('data','images', session['out_preview_image'])) + if kind == 'out_weight' and session.get('out_preview_weight'): + cands.append(os.path.join('data','images', session['out_preview_weight'])) + except Exception: + pass + if sid is not None: + cands.append(f"data/images/evidence_{sid}_{kind}.jpg") + cands.append(f"data/images/evidence_{sid}_{kind}.png") + return cands + + if bool(pcfg.get("print_in_out_images", False)): + xL = m; xR = m + col_w + g; ytop = y + draw_frame(xL, ytop, col_w, row_h_vehicle, session_image_candidates("in_vehicle"), "รูปรถเข้า") + draw_frame(xR, ytop, col_w, row_h_vehicle, session_image_candidates("out_vehicle"), "รูปรถออก") + y += row_h_vehicle + int(0.015*H) + + if bool(pcfg.get("print_weight_images", False)): + xL = m; xR = m + col_w + g; ytop = y + draw_frame(xL, ytop, col_w, row_h_weight, session_image_candidates("in_weight"), "รูปน้ำหนักเข้า") + draw_frame(xR, ytop, col_w, row_h_weight, session_image_candidates("out_weight"), "รูปน้ำหนักออก") + y += row_h_weight + int(0.02*H) + + if bool(pcfg.get("show_time", True)): + _tin = (overrides.get("time_in") if overrides else None) + if not _tin and isinstance(session, dict): + _tin = session.get("in_time") + _tout = (overrides.get("time_out") if overrides else None) + if not _tout and isinstance(session, dict): + _tout = session.get("out_time") + + _tin_txt = "—" + if _tin: + dt = _tin if isinstance(_tin, datetime) else (datetime.fromtimestamp(_tin) if isinstance(_tin, (int, float)) else None) + if dt is None: + s = str(_tin).strip().replace("T", " ").replace("Z", "") + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y/%m/%d %H:%M:%S", "%Y/%m/%d %H:%M"): + try: + dt = datetime.strptime(s, fmt) + break + except: + pass + if dt is None: + try: + dt = datetime.fromisoformat(s) + except: + pass + _tin_txt = dt.strftime("%d/%m/%Y %H:%M") if dt else str(_tin) + + _tout_txt = "—" + if _tout: + dt = _tout if isinstance(_tout, datetime) else (datetime.fromtimestamp(_tout) if isinstance(_tout, (int, float)) else None) + if dt is None: + s = str(_tout).strip().replace("T", " ").replace("Z", "") + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y/%m/%d %H:%M:%S", "%Y/%m/%d %H:%M"): + try: + dt = datetime.strptime(s, fmt) + break + except: + pass + if dt is None: + try: + dt = datetime.fromisoformat(s) + except: + pass + _tout_txt = dt.strftime("%d/%m/%Y %H:%M") if dt else str(_tout) + + center_text("เวลา", int(0.045*W), sep=int(0.012*H), bold=True) + center_text(f"รถเข้า: {_tin_txt}", int(0.04*W), sep=int(0.008*H), bold=False) + center_text(f"รถออก: {_tout_txt}", int(0.04*W), sep=int(0.020*H), bold=False) + + center_text("น้ำหนัก", int(0.045*W), sep=int(0.012*H), bold=True) + + try: + _iw_val = float(in_w) if (in_w is not None and str(in_w).strip()!="") else None + except Exception: + _iw_val = None + try: + _ow_val = float(out_w) if (out_w is not None and str(out_w).strip()!="") else None + except Exception: + _ow_val = None + iw = f"{_iw_val:.0f}" if _iw_val is not None else "xxx" + ow = f"{_ow_val:.0f}" if _ow_val is not None else "xxx" + center_text(f"น้ำหนักเข้า: {iw} kg", int(0.04*W), sep=int(0.008*H), bold=False) + center_text(f"น้ำหนักออก: {ow} kg", int(0.04*W), sep=int(0.012*H), bold=False) + if (_iw_val is not None) and (_ow_val is not None): + net = abs(_iw_val - _ow_val) + center_text(f"น้ำหนักสุทธิ: {net:.0f} kg", int(0.045*W), sep=int(0.02*H), bold=True) + else: + center_text("น้ำหนักสุทธิ: xxx kg", int(0.045*W), sep=int(0.02*H), bold=True) + + if bool(pcfg.get("print_blank_bottom", False)): + img_w = W - m*2 + frame_h = int(0.16*H) + x0, y0 = m, y + d.rounded_rectangle((x0, y0, x0+img_w, y0+frame_h), radius=int(0.02*W), outline=0, width=max(1,int(W*0.002))) + y += frame_h + int(0.02*H) + used_h = max(y + m, int(0.1*H)) + + content_crop = content.crop((0, 0, W, min(used_h, content_h))) + + pct = float((CONFIG.get("printing", {}) or {}).get("scale_percent")) + pct = max(30.0, min(200.0, pct)) + scale = pct / 100.0 + scaled_w = int(W * scale) + scaled_h = int(content_crop.height * scale) + if scaled_h > H: + fit_scale = H / max(1, content_crop.height) + scale = min(scale, fit_scale) + scaled_w = int(W * scale) + scaled_h = int(content_crop.height * scale) + content_scaled = content_crop.resize((scaled_w, scaled_h)) + page = Image.new("L", (W, H), 255) + px = (W - scaled_w)//2 + py = (H - scaled_h)//2 + page.paste(content_scaled, (px, py)) + return page + +def safe_list_windows(): + titles = [] + try: + if hasattr(win_agent, "list_windows"): + titles = win_agent.list_windows() or [] + except Exception: + titles = [] + if titles: + return sorted(set([t for t in titles if isinstance(t, str) and len(t.strip()) >= 2])) + + try: + user32 = ctypes.windll.user32 + EnumWindows = user32.EnumWindows + IsWindowVisible = user32.IsWindowVisible + GetWindowTextLengthW = user32.GetWindowTextLengthW + GetWindowTextW = user32.GetWindowTextW + EnumWindowsProc = ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM) + buf = [] + + def _cb(hwnd, lParam): + try: + if not IsWindowVisible(hwnd): + return True + length = GetWindowTextLengthW(hwnd) + if length > 0: + title_buf = ctypes.create_unicode_buffer(length + 1) + GetWindowTextW(hwnd, title_buf, length + 1) + t = title_buf.value.strip() + if t: + buf.append(t) + except Exception: + pass + return True + EnumWindows(EnumWindowsProc(_cb), 0) + titles = [t for t in buf if len(t.strip()) >= 2] + return sorted(set(titles)) + except Exception: + return [] + +def safe_list_printers(): + try: + flags = win32print.PRINTER_ENUM_LOCAL | win32print.PRINTER_ENUM_CONNECTIONS + names = [] + for entry in win32print.EnumPrinters(flags): + try: + name = entry[2] + if name: + names.append(name) + except Exception: + pass + try: + default = win32print.GetDefaultPrinter() + if default: + names = [default] + [n for n in names if n != default] + except Exception: + pass + seen = set() + ordered = [] + for n in names: + if n not in seen: + seen.add(n); ordered.append(n) + return ordered + except Exception: + return [] + +def safe_list_paper_sizes(printer_name): + try: + if not printer_name: + return [58, 76, 80] + h = win32print.OpenPrinter(printer_name) + try: + forms = win32print.EnumForms(h, 1) + widths = [] + for f in forms: + try: + sz = f['Size'] + w_mm = int(round(sz['Right'] / 1000.0)) + if 40 <= w_mm <= 120: + widths.append(w_mm) + except Exception: + continue + widths += [58, 60, 72, 76, 80, 82] + seen = set(); uniq = [] + for w in widths: + if w not in seen: + seen.add(w); uniq.append(w) + return sorted(uniq) + finally: + try: + win32print.ClosePrinter(h) + except Exception: + pass + except Exception: + return [58, 76, 80] + +def _to_int(v): + if v is None: return None + if isinstance(v, int): return v + if isinstance(v, str) and v.strip().lower().startswith("0x"): return int(v, 16) + return int(v) + +def load_print_params(CONFIG): + p = (CONFIG.get("printing", {}) or {}) + def get(name, default=None): + return _get_print_param(name, p.get(name, default)) + + usb_cfg = (p.get("escpos_usb") or {}) + return { + "vid": _to_int(get("vid", usb_cfg.get("vid"))), + "pid": _to_int(get("pid", usb_cfg.get("pid"))), + "pw": int(get("print_width", p.get("print_width"))), + "th": int(get("threshold", p.get("threshold"))), + "inv": bool(get("invert")), + "dith": bool(get("dither")), + "chunk": int(get("max_cmd_bytes", p.get("max_cmd_bytes"))), + "driver": p.get("driver"), + "host": p.get("escpos_net_host"), + "port": p.get("escpos_net_port"), + } + +def safe_float(x, default): + with suppress(Exception): + s = str(x).strip() + if s != "": + return float(s) + return default + +def csv_ints_or(s, default): + with suppress(Exception): + return [int(x) for x in str(s).split(",")] + return default + +def clamp01(v): + return 0.0 if v < 0.0 else (1.0 if v > 1.0 else v) + +def on(data, key): + return data.get(key, "off") == "on" + +def pick_first(*vals): + for v in vals: + if v not in (None, ""): + return v + return None + +def do(fn, *a, **kw): + with suppress(Exception): + fn(*a, **kw) + +def restart_cam_if_changed(changed, new_cfg): + if not changed: + return + def _restart(): + with suppress(Exception): + if camera_mgr: + camera_mgr.stop() + time.sleep(0.3) + globals()['camera_mgr'] = CameraManager(new_cfg) + threading.Thread(target=_restart, daemon=True).start() + +def ensure_finance_columns(): + cols = {r["name"] for r in db_query("PRAGMA table_info(sessions)")} + if "price" not in cols: + db_execute("ALTER TABLE sessions ADD COLUMN price REAL DEFAULT 0", ()) + if "add_amount" not in cols: + db_execute("ALTER TABLE sessions ADD COLUMN add_amount REAL DEFAULT 0", ()) + if "deduct_amount" not in cols: + db_execute("ALTER TABLE sessions ADD COLUMN deduct_amount REAL DEFAULT 0", ()) + if "amount" not in cols: + db_execute("ALTER TABLE sessions ADD COLUMN amount REAL DEFAULT 0", ()) + +def net_of(row): + iw = row.get('in_weight') or 0 + ow = row.get('out_weight') or 0 + try: + return abs(float(ow) - float(iw)) + except Exception: + return 0.0 + +@app.post("/api/print/preview") +def api_print_preview(): + try: + data = request.get_json(silent=True) or {} + try: + LAST_PRINT_OVERRIDES.clear(); LAST_PRINT_OVERRIDES.update(dict(data)) + except Exception: + pass + sample = None + try: + rows = [] if ((data.get('no_sample') in (True, 'true', '1')) if 'data' in locals() else False) else db_query("SELECT * FROM sessions ORDER BY id DESC LIMIT 1") + sample = rows[0] if rows else None + except Exception: + sample = None + try: + inject_flag = (str(request.args.get("inject", "0")).lower() in ("1","true","on")) + except Exception: + inject_flag = False + if inject_flag: + try: + frame = camera_mgr.grab_frame() + if frame is not None: + cx,cy,cw,ch = CONFIG.get("cctv",{}).get("roi",[0,0,0,0]) + if cw and ch: + frame = frame[cy:cy+ch, cx:cx+cw].copy() + os.makedirs("data", exist_ok=True) + cv2.imwrite("data/images/evidence_PREVIEW_in_vehicle.jpg", frame) + except Exception as _e: + print("WARN: preview inject cctv failed:", _e) + img = render_receipt_image(CONFIG, overrides=data, session=sample, for_print=False) + bio = io.BytesIO() + img.save(bio, format="PNG") + bio.seek(0) + return Response(bio.getvalue(), content_type="image/png") + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + +@app.post("/api/print/direct") +def api_print_direct(): + try: + data = request.get_json(silent=True) or {} + try: + LAST_PRINT_OVERRIDES.clear(); LAST_PRINT_OVERRIDES.update(dict(data)) + except Exception: + pass + sample = None + try: + if str(data.get('no_sample')).lower() in ('1','true','on'): + rows = [] + else: + rows = db_query("SELECT * FROM sessions ORDER BY id DESC LIMIT 1") + sample = rows[0] if rows else None + except Exception: + sample = None + img = render_receipt_image(CONFIG, overrides=_get_print_overrides(), session=sample, for_print=True) + params = load_print_params(CONFIG) + _mode = str(((CONFIG.get("printing", {}) or {}).get("ticket_mode") or "detailed")).lower() + if _mode in ("detailed","both"): + w, h = img.size + usb_direct_print_image( + img, + vid=params["vid"], + pid=params["pid"], + print_width=params["pw"], + threshold=params["th"], + invert=params["inv"], + dither=params["dith"], + max_cmd_bytes=params["chunk"], + driver=params["driver"], + escpos_net_host=params["host"], + escpos_net_port=params["port"], + ) + log_event("print", "Direct USB sample print executed") + return jsonify({"ok": True}) + except Exception as e: + log_event("print", f"Direct USB print error: {e}") + return jsonify({"ok": False, "error": str(e)}), 500 + +@app.post("/api/records//print") +def api_records_print(session_id): + try: + sess = compose_session_view(session_id) + if not sess: + return jsonify({'ok': False, 'error': 'not_found'}), 404 + img = render_receipt_image(CONFIG, overrides=_get_print_overrides(), session=sess, for_print=True) + params = load_print_params(CONFIG) + _mode = str(((CONFIG.get("printing", {}) or {}).get("ticket_mode") or "detailed")).lower() + if _mode in ("detailed","both"): + w, h = img.size + usb_direct_print_image( + img, + vid=params["vid"], + pid=params["pid"], + print_width=params["pw"], + threshold=params["th"], + invert=params["inv"], + dither=params["dith"], + max_cmd_bytes=params["chunk"], + driver=params["driver"], + escpos_net_host=params["host"], + escpos_net_port=params["port"], + ) + + db_execute("UPDATE sessions SET print_status=? WHERE id=?", ("printed", session_id)) + log_event("print", f"Reprint USB-direct #{session_id}") + return jsonify({"ok": True}) + except Exception as e: + log_event("print", f"Reprint error for {session_id}: {e}") + return jsonify({"ok": False, "error": str(e)}), 500 + + +@app.get("/api/records//preview") +def api_records_preview(session_id): + try: + sess = compose_session_view(session_id) + if not sess: + return jsonify({'ok': False, 'error': 'not_found'}), 404 + img = render_receipt_image(CONFIG, overrides=_get_print_overrides(), session=sess, for_print=False) + bio = io.BytesIO() + img.save(bio, format="PNG") + bio.seek(0) + return Response(bio.getvalue(), content_type="image/png") + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + +@app.delete("/api/records/") +def api_records_delete(session_id): + try: + rows = db_query("SELECT id, status FROM sessions WHERE id=?", (session_id,)) + if not rows: + return jsonify({"ok": False, "error": "not_found"}), 404 + st = str(rows[0]["status"] or "").strip().lower() + if st not in ("expired","canceled"): + return jsonify({"ok": False, "error": "forbidden"}), 400 + try: + for kind in ("in_vehicle","out_vehicle","in_weight","out_weight"): + p = resolve_data_file("images", f"evidence_{int(session_id)}_{kind}.jpg") + if os.path.exists(p): + try: os.remove(p) + except Exception: pass + except Exception: + pass + db_execute("DELETE FROM sessions WHERE id=?", (session_id,)) + log_event("delete", f"Delete session {session_id} ({st})") + return jsonify({"ok": True}) + except Exception as e: + log_event("delete", f"Delete error for {session_id}: {e}") + return jsonify({"ok": False, "error": str(e)}), 500 + +@app.post("/api/records/merge_pair") +def api_records_merge_pair(): + data = request.get_json(silent=True) or {} + in_id = int(data.get("in_id") or 0) + out_id = int(data.get("out_id") or 0) + if not in_id or not out_id or in_id == out_id: + return jsonify({"ok": False, "error": "invalid_ids"}), 400 + + sin_rows = db_query("SELECT * FROM sessions WHERE id=?", (in_id,)) + sout_rows = db_query("SELECT * FROM sessions WHERE id=?", (out_id,)) + if not sin_rows or not sout_rows: + return jsonify({"ok": False, "error": "record_not_found"}), 404 + sin, sout = dict(sin_rows[0]), dict(sout_rows[0]) + + has_out_b = (sout.get("out_time") is not None) or (sout.get("out_weight") is not None) + + if has_out_b: + out_time = sout.get("out_time") or sout.get("in_time") + out_weight = sout.get("out_weight") if (sout.get("out_weight") is not None) else sout.get("in_weight") + db_execute("UPDATE sessions SET out_time=?, out_weight=?, status=?, updated_at=? WHERE id=?", + (out_time, out_weight, "completed", now_ts(), in_id)) + db_execute("UPDATE sessions SET out_time=NULL, out_weight=NULL, status=?, updated_at=? WHERE id=?", + ("canceled", now_ts(), out_id)) + p = lambda sid, kind: resolve_data_file("images", f"evidence_{int(sid)}_{kind}.jpg") + def mv(src, dst): + if os.path.exists(src): + if os.path.exists(dst): os.remove(dst) + os.replace(src, dst) + mv(p(out_id, "out_vehicle") if os.path.exists(p(out_id, "out_vehicle")) else p(out_id, "in_vehicle"), + p(in_id, "out_vehicle")) + mv(p(out_id, "out_weight") if os.path.exists(p(out_id, "out_weight")) else p(out_id, "in_weight"), + p(in_id, "out_weight")) + log_event("merge", f"merge B(out #{out_id}) -> A(in #{in_id})") + return jsonify({"ok": True}) + + out_time = sout.get("in_time") + out_weight = sout.get("in_weight") + db_execute("UPDATE sessions SET out_time=?, out_weight=?, status=?, updated_at=? WHERE id=?", + (out_time, out_weight, "completed", now_ts(), in_id)) + db_execute("UPDATE sessions SET in_time=NULL, in_weight=NULL, out_time=NULL, out_weight=NULL, status=?, updated_at=? WHERE id=?", + ("canceled", now_ts(), out_id)) + p = lambda sid, kind: resolve_data_file("images", f"evidence_{int(sid)}_{kind}.jpg") + def mv(src, dst): + if os.path.exists(src): + if os.path.exists(dst): os.remove(dst) + os.replace(src, dst) + mv(p(out_id, "in_vehicle"), p(in_id, "out_vehicle")) + mv(p(out_id, "in_weight"), p(in_id, "out_weight")) + log_event("merge", f"merge B(in #{out_id}) -> A(in #{in_id})") + return jsonify({"ok": True}) + +@app.post("/api/records/swap_ab") +def api_records_swap_ab(): + try: + data = request.get_json(silent=True) or {} + in_id = int(data.get("in_id") or 0) + out_id = int(data.get("out_id") or 0) + if not in_id or not out_id: + return jsonify({"ok": False, "error": "invalid_ids"}), 400 + sin_rows = db_query("SELECT * FROM sessions WHERE id=?", (in_id,)) + sout_rows = db_query("SELECT * FROM sessions WHERE id=?", (out_id,)) + if not sin_rows or not sout_rows: + return jsonify({"ok": False, "error": "record_not_found"}), 404 + sin = dict(sin_rows[0]) + sout = dict(sout_rows[0]) + + def p(session_id, kind): + return resolve_data_file("images", f"evidence_{int(session_id)}_{kind}.jpg") + + if in_id == out_id: + t_in_time, t_in_weight = sin.get("in_time"), sin.get("in_weight") + t_out_time, t_out_weight = sin.get("out_time"), sin.get("out_weight") + db_execute( + "UPDATE sessions SET in_time=?, in_weight=?, out_time=?, out_weight=?, updated_at=? WHERE id=?", + (t_out_time, t_out_weight, t_in_time, t_in_weight, now_ts(), in_id), + ) + a1, a2 = p(in_id, "in_vehicle"), p(in_id, "out_vehicle") + b1, b2 = p(in_id, "in_weight"), p(in_id, "out_weight") + for x, y in ((a1, a2), (b1, b2)): + if os.path.exists(x) or os.path.exists(y): + tmp = x + ".tmp" + if os.path.exists(tmp): + try: os.remove(tmp) + except Exception: pass + if os.path.exists(x): os.replace(x, tmp) + if os.path.exists(y): os.replace(y, x) + if os.path.exists(tmp): os.replace(tmp, y) + return jsonify({"ok": True}) + else: + a_in_time, a_in_weight = sin.get("in_time"), sin.get("in_weight") + b_out_time, b_out_weight = sout.get("out_time"), sout.get("out_weight") + db_execute( + "UPDATE sessions SET in_time=?, in_weight=?, updated_at=? WHERE id=?", + (b_out_time, b_out_weight, now_ts(), in_id), + ) + db_execute( + "UPDATE sessions SET out_time=?, out_weight=?, updated_at=? WHERE id=?", + (a_in_time, a_in_weight, now_ts(), out_id), + ) + f1_src, f1_dst = p(out_id, "out_vehicle"), p(in_id, "in_vehicle") + f2_src, f2_dst = p(out_id, "out_weight"), p(in_id, "in_weight") + f3_src, f3_dst = p(in_id, "in_vehicle"), p(out_id, "out_vehicle") + f4_src, f4_dst = p(in_id, "in_weight"), p(out_id, "out_weight") + for src, dst in ((f1_src, f1_dst), (f2_src, f2_dst), (f3_src, f3_dst), (f4_src, f4_dst)): + if os.path.exists(src): + if os.path.exists(dst): + tmp = dst + ".bak" + try: + if os.path.exists(tmp): os.remove(tmp) + except Exception: + pass + os.replace(dst, tmp) + os.replace(src, dst) + try: os.remove(tmp) + except Exception: pass + else: + os.replace(src, dst) + log_event("swap", f"swap A(in #{in_id}) <-> B(out #{out_id})") + return jsonify({"ok": True}) + except Exception as e: + log_event("swap", f"error: {e}") + return jsonify({"ok": False, "error": str(e)}), 500 + +@app.post("/api/records/print_pair") +def api_print_pair(): + try: + data = request.get_json(silent=True) or {} + in_id = int(data.get("in_id") or 0) + out_id = int(data.get("out_id") or 0) + promote = bool(data.get("treat_out_from_in", False)) + + if not in_id or not out_id or in_id == out_id: + return jsonify({"ok": False, "error": "invalid_ids"}), 400 + + sin_rows = db_query("SELECT * FROM sessions WHERE id=?", (in_id,)) + sout_rows = db_query("SELECT * FROM sessions WHERE id=?", (out_id,)) + if not sin_rows or not sout_rows: + return jsonify({"ok": False, "error": "record_not_found"}), 404 + + sin = dict(sin_rows[0]) + sout = dict(sout_rows[0]) + + def has_out(r): + return bool(r.get("out_time")) or (r.get("out_weight") is not None) + + sess = dict(sin) + + sess["in_preview_image"] = url_for("evidence", filename=f"evidence_{in_id}_in_vehicle.jpg", _external=False) + sess["in_preview_weight"] = url_for("evidence", filename=f"evidence_{in_id}_in_weight.jpg", _external=False) + + if promote or not has_out(sout): + sess["out_time"] = sout.get("in_time") + sess["out_weight"] = sout.get("in_weight") + sess["out_preview_image"] = url_for("evidence", filename=f"evidence_{out_id}_in_vehicle.jpg", _external=False) + sess["out_preview_weight"] = url_for("evidence", filename=f"evidence_{out_id}_in_weight.jpg", _external=False) + else: + sess["out_time"] = sout.get("out_time") + sess["out_weight"] = sout.get("out_weight") + sess["out_preview_image"] = url_for("evidence", filename=f"evidence_{out_id}_out_vehicle.jpg", _external=False) + sess["out_preview_weight"] = url_for("evidence", filename=f"evidence_{out_id}_out_weight.jpg", _external=False) + + overrides = _get_print_overrides() + img = render_receipt_image(CONFIG, overrides=overrides, session=sess, for_print=True) + + params = load_print_params(CONFIG) + _mode = str(((CONFIG.get("printing", {}) or {}).get("ticket_mode") or "detailed")).lower() + if _mode in ("detailed", "both"): + usb_direct_print_image( + img, + vid=params["vid"], pid=params["pid"], + print_width=params["pw"], threshold=params["th"], + invert=params["inv"], dither=params["dith"], + max_cmd_bytes=params["chunk"], + driver=params["driver"], + escpos_net_host=params["host"], + escpos_net_port=params["port"], + ) + + log_event("print", f"Pair print in#{in_id} out#{out_id} promote={promote}") + return jsonify({"ok": True}) + except Exception as e: + log_event("print", f"Pair print error: {e}") + return jsonify({"ok": False, "error": str(e)}), 500 + +@app.post("/api/records//product") +def api_records_product(sess_id): + data = request.get_json(silent=True) or {} + product = (data.get("product") or "").strip() + db_execute("UPDATE sessions SET product=? WHERE id=?", (product, sess_id)) + return jsonify({"ok": True, "id": sess_id, "product": product}) + +@app.get("/api/records/export.csv") +def api_records_export_csv(): + ensure_finance_columns() + ids = request.args.get("ids", "").strip() + if not ids: + return jsonify({"ok": False, "error": "no_ids"}), 400 + id_list = [int(x) for x in ids.split(",") if x.isdigit()] + if not id_list: + return jsonify({"ok": False, "error": "no_ids"}), 400 + placeholders = ",".join(["?"] * len(id_list)) + q = f"SELECT id,in_time,out_time,in_weight,out_weight,product,price,add_amount,deduct_amount,amount,status FROM sessions WHERE id IN ({placeholders})" + rows = [dict(r) for r in db_query(q, tuple(id_list))] + order = {i: idx for idx, i in enumerate(id_list)} + rows.sort(key=lambda r: order.get(r["id"], 10**9)) + for r in rows: + if r.get("amount") is None: + r["amount"] = (net_of(r) * float(r.get("price") or 0)) + float(r.get("add_amount") or 0) - float(r.get("deduct_amount") or 0) + total_net = sum(net_of(r) for r in rows) + total_amount = sum(float(r.get("amount") or 0) for r in rows) + out = io.StringIO() + w = csv.writer(out) + w.writerow(["ID","In Time","Out Time","In W","Out W","Net","สินค้า","ราคา","เพิ่ม","หัก","จำนวนเงิน","สถานะ"]) + for r in rows: + w.writerow([ + r["id"], r.get("in_time") or "", r.get("out_time") or "", + r.get("in_weight") or 0, r.get("out_weight") or 0, net_of(r), + r.get("product") or "", r.get("price") or 0, r.get("add_amount") or 0, r.get("deduct_amount") or 0, + round(float(r.get("amount") or 0), 2), r.get("status") or "" + ]) + w.writerow(["รวม","","","", "", total_net,"","","","", round(total_amount,2), ""]) + resp = make_response(out.getvalue().encode("utf-8-sig")) + resp.headers["Content-Type"] = "text/csv; charset=utf-8" + resp.headers["Content-Disposition"] = 'attachment; filename="records_export.csv"' + return resp + +@app.post("/api/records/finance") +def api_records_finance(): + ensure_finance_columns() + data = request.get_json(silent=True) or {} + sid = int(data.get("id") or 0) + price = float(data.get("price") or 0) + add_amount = float(data.get("add_amount") or 0) + deduct_amount = float(data.get("deduct_amount") or 0) + rows = db_query("SELECT id,in_weight,out_weight FROM sessions WHERE id=?", (sid,)) + if not rows: + return jsonify({"ok": False, "error": "not_found"}), 404 + s = dict(rows[0]) + cur = db_query('SELECT price,add_amount,deduct_amount,amount FROM sessions WHERE id=?', (sid,)) + if cur: + curd = dict(cur[0]) + expected_amount = (net_of(s) * price) + add_amount - deduct_amount + if abs(float(curd.get('price') or 0) - price) < 1e-9 and abs(float(curd.get('add_amount') or 0) - add_amount) < 1e-9 and abs(float(curd.get('deduct_amount') or 0) - deduct_amount) < 1e-9 and abs(float(curd.get('amount') or 0) - expected_amount) < 1e-9: + return jsonify({'ok': True, 'amount': expected_amount}) + amount = (net_of(s) * price) + add_amount - deduct_amount + db_execute( + "UPDATE sessions SET price=?, add_amount=?, deduct_amount=?, amount=?, updated_at=? WHERE id=?", + (price, add_amount, deduct_amount, amount, now_ts(), sid), + ) + return jsonify({"ok": True, "amount": amount}) + +@app.route("/") +def index(): + return render_template("dashboard.html", config=CONFIG) + +@app.route("/customer") +def customer_display(): + rows = db_query("SELECT * FROM sessions ORDER BY id DESC LIMIT 10") + return render_template("customer.html", rows=rows, config=CONFIG) + +@app.route("/records") +def records(): + ensure_finance_columns() + rows = db_query("SELECT * FROM sessions ORDER BY id DESC LIMIT 200") + return render_template("records.html", rows=rows, config=CONFIG) + +@app.route("/logs") +def logs(): + rows = db_query("SELECT * FROM logs ORDER BY id DESC LIMIT 500") + return render_template("logs.html", rows=rows, config=CONFIG) + +@app.route("/settings", methods=["GET", "POST"]) +def settings(): + global CONFIG, camera_mgr + if request.method == "POST": + data = request.form.to_dict() + + CONFIG["lan_port"] = safe_int(data.get("lan_port", CONFIG.get("lan_port", APP_PORT))) + CONFIG["allow_online"] = on(data, "allow_online") + CONFIG["customer_display_title"] = data.get("customer_display_title", CONFIG.get("customer_display_title","")) + + ocr = CONFIG.setdefault("ocr", {}) + ocr["source"] = data.get("ocr_source", ocr.get("source","window")) + ocr["stable_hsv_low"] = csv_ints_or(data.get("ocr_hsv_low", ",".join(map(str, ocr.get("stable_hsv_low",[0,100,100])))), ocr.get("stable_hsv_low",[0,100,100])) + ocr["stable_hsv_high"] = csv_ints_or(data.get("ocr_hsv_high", ",".join(map(str, ocr.get("stable_hsv_high",[10,255,255])))), ocr.get("stable_hsv_high",[10,255,255])) + ocr["min_weight"] = safe_int(data.get("ocr_min_weight", str(ocr.get("min_weight")))) + ocr["settle_seconds"] = safe_int(data.get("ocr_settle_seconds", str(ocr.get("settle_seconds", 4)))) + ocr["show_roi"] = on(data, "ocr_show_roi") + ocr["window_title"] = data.get("ocr_window_title", ocr.get("window_title","")) + ocr["ipcam_rtsp"] = data.get("ocr_ipcam_rtsp", ocr.get("ipcam_rtsp","")) + + s_cfg = dict(ocr.get("serial", {})) + s_cfg["port"] = data.get("serial_port", s_cfg.get("port","COM4")) + s_cfg["baudrate"] = safe_int(data.get("serial_baudrate", s_cfg.get("baudrate", 1200))) + s_cfg["bytesize"] = safe_int(data.get("serial_bytesize", s_cfg.get("bytesize", 7))) + s_cfg["parity"] = (data.get("serial_parity", s_cfg.get("parity","E"))[:1]).upper() + s_cfg["stopbits"] = safe_float(data.get("serial_stopbits", s_cfg.get("stopbits", 1)), 1.0) + s_cfg["timeout"] = safe_float(data.get("serial_timeout", s_cfg.get("timeout", 1.0)), 1.0) + s_cfg["pattern"] = data.get("serial_pattern", s_cfg.get("pattern", R"(\\d+)")) + s_cfg["mode"] = (data.get("serial_mode", s_cfg.get("mode","com")) or "com").lower() + s_cfg["ip_host"] = data.get("serial_ip_host", s_cfg.get("ip_host","127.0.0.1")) + s_cfg["ip_port"] = safe_int(data.get("serial_ip_port", s_cfg.get("ip_port", 5000))) + ocr["serial"] = s_cfg + do(serial_mgr.update_config, s_cfg) + + cctv = CONFIG.setdefault("cctv", {}) + old_rtsp = cctv.get("rtsp","") + cctv["enabled"] = True + cctv["rtsp"] = data.get("cctv_rtsp","") + cctv["show_roi"] = on(data, "cctv_show_roi") + cctv["detect_people"] = on(data, "cctv_detect_people") + cctv["detect_vehicles"] = on(data, "cctv_detect_vehicles") + rtsp_changed = (cctv["rtsp"] != old_rtsp) + + cctv_ocr = CONFIG.setdefault("cctv_ocr", {}) + cctv_ocr["rtsp"] = data.get("cctv_ocr_rtsp", cctv_ocr.get("rtsp","")) + cctv_ocr["show_roi"] = on(data, "cctv_ocr_show_roi") + + w = CONFIG.setdefault("weighing", {}) + w["wait_out_seconds"] = safe_int(pick_first(data.get("wait_out_seconds"), data.get("settle_seconds"), + data.get("wait_out_minutes"), str(w.get("wait_out_seconds")))) + w["min_weight"] = safe_int(data.get("min_weight", str(w.get("min_weight")))) + w["high_weight"] = safe_int(data.get("high_weight", str(w.get("high_weight")))) + w["settle_seconds"] = safe_int(data.get("settle_seconds", str(w.get("settle_seconds")))) + w["unmatched_timeout_minutes"] = safe_int(data.get("unmatched_timeout_minutes", str(w.get("unmatched_timeout_minutes")))) + + m = CONFIG.setdefault("matching", {}) + m["threshold"] = clamp01(safe_float(data.get("threshold", m.get("threshold", 0.6)), 0.6)) + m["threshold_multi"] = clamp01(safe_float(data.get("threshold_multi", m.get("threshold_multi", 0.72)), 0.72)) + + p = CONFIG.setdefault("printing", {}) + p["header_name"] = data.get("header_name", p.get("header_name","")) + p["header_address"]= data.get("header_address", p.get("header_address","")) + p["header_phone"] = data.get("header_phone", p.get("header_phone","")) + p["print_evidence_images"] = on(data, "print_evidence_images") + p["print_in_out_images"] = on(data, "print_in_out_images") + p["print_weight_images"] = on(data, "print_weight_images") + p["print_blank_bottom"] = on(data, "print_blank_bottom") + p["auto_print_when_complete"] = on(data, "auto_print_when_complete") + p["driver"] = data.get("print_driver", p.get("driver","escpos")) + p["windows_printer_name"] = data.get("windows_printer_name", p.get("windows_printer_name","")) + + base_from = data.get("weight_image_from") + p["weight_image_from"] = pick_first(base_from, p.get("weight_image_from"), "cctv_ocr_evidence") + p["weight_in_image_from"] = pick_first(data.get("weight_in_image_from"), p.get("weight_in_image_from"), base_from or 'cctv_ocr_evidence') + p["weight_out_image_from"] = pick_first(data.get("weight_out_image_from"), p.get("weight_out_image_from"), base_from or 'cctv_ocr_evidence') + + p["spacing_scale"] = safe_float(data.get("spacing_scale", p.get("spacing_scale")), p.get("spacing_scale", 1.0)) + p["font_path"] = data.get("font_path", p.get("font_path","")) + p["scale_percent"] = safe_float(data.get("scale_percent", p.get("scale_percent", 100)), p.get("scale_percent", 100)) + p["font_scale_percent"]= safe_float(data.get("font_scale_percent",p.get("font_scale_percent", 100)), p.get("font_scale_percent", 100)) + p["show_time"] = on(data, "show_time") + p["escpos_net_host"] = (data.get("escpos_net_host") or "").strip() + p["escpos_net_port"] = safe_int(data.get("escpos_net_port", p.get("escpos_net_port", 9100))) + + _w = CONFIG.setdefault("weighing", {}) + _pl = (data.get("weighing_product_list") or "").replace("\r", "") + products, prices = [], {} + for line in _pl.split("\n"): + s = line.strip() + if not s: + continue + if ":" in s: + n, v = s.split(":", 1) + n = n.strip() + try: + p = float(v.strip()) + except Exception: + p = 0.0 + else: + n, p = s, 0.0 + products.append(n) + prices[n] = p + _w["products"] = products + _w["product_prices"] = prices + + + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(CONFIG, f, ensure_ascii=False, indent=2) + + restart_cam_if_changed(rtsp_changed, CONFIG) + return redirect(url_for("settings")) + + return render_template( + "settings.html", + config=CONFIG, + windows=safe_list_windows(), + printers=safe_list_printers(), + paper_sizes=safe_list_paper_sizes(CONFIG["printing"].get("windows_printer_name","")) + ) + +@app.route("/webrtc/") +def webrtc_view(source): + if source not in ("cctv","ocr"): + source = "cctv" + return render_template("webrtc.html", source=source) + +@app.route("/webrtc/offer/", methods=["POST"]) +def webrtc_offer(source): + data = request.get_json(force=True) or {} + fps = int(request.args.get("fps", 25)) + + def grab_cctv(): + frame = camera_mgr.grab_frame() + if frame is None: + return None + + if CONFIG["cctv"].get("show_roi", True): + draw_roi(frame, tuple(CONFIG["cctv"]["roi"])) + + try: + x,y,w,h = CONFIG["cctv"]["roi"] + out = detector_mgr.process_frame(frame, tuple(CONFIG["cctv"]["roi"])) + boxes = out.get("vehicle_boxes") or [] + if boxes: + i, _ = max( + enumerate(boxes), + key=lambda t: (t[1][2]*t[1][3]) + ) + bx,by,bw,bh = boxes[i] + cv2.rectangle(frame, (x+bx, y+by), (x+bx+bw, y+by+bh), (0,255,0), 2) + cv2.putText(frame, "crop-used", (x+bx, max(0, y+by-6)), + cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0,255,0), 2, cv2.LINE_AA) + except Exception: + pass + + return frame + + def grab_ocr(): + if CONFIG["ocr"].get("source") == "window": + return win_agent.capture_window_frame() + else: + return camera_mgr.grab_frame_from_ocr_source() + grabber = grab_cctv if source == "cctv" else grab_ocr + answer = webrtc_mgr.create_answer(data, grabber=grabber, fps=fps) + resp = jsonify(answer) + resp.headers["Cache-Control"] = "no-store" + return resp + +@app.route("/api/windows") +def api_windows(): + try: + titles = win_agent.list_windows() + return jsonify({"titles": titles}) + except Exception as e: + return jsonify({"titles": [], "error": str(e)}), 500 + +@app.route("/api/config") +def api_config(): + try: + cfg = CONFIG or {} + out = { + "customer_display_title": cfg.get("customer_display_title") or "", + "printing": { + "header_name": (cfg.get("printing", {}) or {}).get("header_name",""), + "header_address": (cfg.get("printing", {}) or {}).get("header_address",""), + "header_phone": (cfg.get("printing", {}) or {}).get("header_phone","") + }, + "ocr": { + "source": (cfg.get("ocr", {}) or {}).get("source",""), + "settle_seconds": (cfg.get("ocr", {}) or {}).get("settle_seconds",4), + "min_weight": (cfg.get("ocr", {}) or {}).get("min_weight",0), + "serial": (cfg.get("ocr", {}) or {}).get("serial",{}) + }, + "weighing": { + "min_weight": (cfg.get("weighing", {}) or {}).get("min_weight",0), + "high_weight": (cfg.get("weighing", {}) or {}).get("high_weight",5000), + "settle_seconds": (cfg.get("weighing", {}) or {}).get("settle_seconds",4) + } + } + try: + mtime = os.path.getmtime(CONFIG_PATH) + except Exception: + mtime = 0.0 + digest_src = json.dumps(out, sort_keys=True, ensure_ascii=False).encode("utf-8") + str(mtime).encode() + etag = f'W/"cfg:{hashlib.md5(digest_src).hexdigest()}"' + if request.headers.get("If-None-Match") == etag: + resp = jsonify({"ok": True}); resp.status_code = 304; resp.set_data(b""); return resp + resp = jsonify(out) + resp.headers["ETag"] = etag + resp.headers["Cache-Control"] = "no-cache" + return resp + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/serial/raw") +def api_serial_raw(): + try: + val = serial_mgr.get_latest() + stable = bool(serial_mgr.is_stable(CONFIG["ocr"]["settle_seconds"])) + try: + last_change = float(getattr(serial_mgr, "_last_change", 0.0)) + except Exception: + last_change = 0.0 + etag = f'W/"sr:{last_change:.6f}:{1 if stable else 0}"' + if request.headers.get("If-None-Match") == etag: + resp = jsonify({"ok": True}); resp.status_code = 304; resp.set_data(b""); return resp + source = (CONFIG.get("ocr",{}).get("source") or "").lower() + serial_cfg = CONFIG.get("ocr",{}).get("serial",{}) if source=="serial" else {} + resp = jsonify({ + "ok": True, + "weight_kg": val, + "stable": stable, + "is_com_mode": (source == "serial"), + "mode": (serial_cfg.get("mode") or "com") if source=="serial" else None, + "latest_value": val, + "latest_ts": time.time() + }) + resp.headers["ETag"] = etag + resp.headers["Cache-Control"] = "no-cache" + return resp + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + +@app.route("/api/serial/ports") +def api_serial_ports(): + ports = [] + try: + ports = [p.device for p in serial.tools.list_ports.comports()] + except Exception: + ports = [] + return jsonify({"ports": ports}) + +@app.route("/api/printers") +def api_printers(): + try: + return jsonify({"printers": safe_list_printers()}) + except Exception as e: + return jsonify({"printers": [], "error": str(e)}), 500 + +@app.route("/api/printer_papers") +def api_printer_papers(): + try: + name = (request.args.get("printer") or "").strip() + sizes = safe_list_paper_sizes(name) if name else [] + return jsonify({"printer": name, "paper_width_mm": sizes}) + except Exception as e: + return jsonify({"printer":"", "paper_width_mm": [], "error": str(e)}), 500 + +@app.route("/api/display_state") +def api_display_state(): + rows = db_query("SELECT * FROM sessions ORDER BY id DESC LIMIT 50") + queue = [r for r in rows if (r.get('status') or '') in ('in_queue','in_progress')][::-1] + + current = None + for r in rows: + st = (r.get('status') or '').lower() + if st in ('in_progress','in_queue'): + current = r; break + + status = (DISPLAY_STATE.get("status") or "idle").lower() + label = CUSTOMER_LABEL.get(status, "") + message = DISPLAY_STATE.get("message") or label + + last_row_id = rows[0]['id'] if rows else 0 + q_first = queue[0]['id'] if queue else 0 + q_last = queue[-1]['id'] if queue else 0 + etag = f'W/"ds:{status}:{hash(message)%10000}:{last_row_id}:{q_first}:{q_last}:{len(queue)}"' + if request.headers.get("If-None-Match") == etag: + resp = jsonify({"ok": True}); resp.status_code = 304; resp.set_data(b""); return resp + + resp = jsonify({"status": status, "label": label, "message": message, "queue": queue, "current": current}) + resp.headers["ETag"] = etag + resp.headers["Cache-Control"] = "no-cache" + return resp + +@app.route("/api/customer_queue") +def api_customer_queue(): + try: + limit = int((request.args.get("limit") or "50").strip()) + except Exception: + limit = 50 + + statuses_param = (request.args.get("statuses") or "").strip() + if statuses_param: + allow = tuple(s.strip().lower() for s in statuses_param.split(",") if s.strip()) + else: + allow = ("in_queue","in_progress") + + rows = db_query("SELECT * FROM sessions ORDER BY id DESC LIMIT 200") + q = [r for r in rows if (r.get("status") or "").lower() in allow] + q = list(reversed(q)) + queue = list(map(_fmt_customer_session, q))[:limit] + + current = None + for r in rows: + if (r.get("status") or "").lower() == "in_progress": + current = _fmt_customer_session(r); break + if current is None and queue: + current = queue[0] + + last_id = rows[0]["id"] if rows else 0 + cur_id = (current or {}).get("id") or 0 + q_first = queue[0]["id"] if queue else 0 + q_last = queue[-1]["id"] if queue else 0 + etag = f'W/"cq:{last_id}:{cur_id}:{q_first}:{q_last}:{len(queue)}:{",".join(allow)}"' + if request.headers.get("If-None-Match") == etag: + resp = jsonify({"ok": True}); resp.status_code = 304; resp.set_data(b""); return resp + + resp = jsonify({"has_queue": len(queue)>0, "count": len(queue), + "current": current, "queue": queue, "ts": now_ts()}) + resp.headers["ETag"] = etag + resp.headers["Cache-Control"] = "no-cache" + return resp + +def _fmt_customer_session(row): + return { + "id": row.get("id"), + "status": row.get("status"), + "in_time": row.get("in_time"), + "in_weight": row.get("in_weight"), + "out_weight": row.get("out_weight"), + "people_out_count": row.get("people_out_count"), + "in_preview_image": row.get("in_preview_image"), + "out_preview_image": row.get("out_preview_image"), + "in_preview_weight": row.get("in_preview_weight"), + "match_status": row.get("match_status"), + "print_status": row.get("print_status"), + "match_score": row.get("match_score"), + } + +@app.route("/api/ocr/show_roi", methods=["POST"]) +def api_ocr_show_roi(): + try: + data = request.get_json(force=True) if request.is_json else request.form.to_dict() + except Exception: + data = {} + val = str(data.get("show_roi","true")).lower() in ("1","true","yes","on") + CONFIG["ocr"]["show_roi"] = val + try: + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(CONFIG, f, ensure_ascii=False, indent=2) + except Exception: + pass + return jsonify({"ok": True, "show_roi": CONFIG["ocr"]["show_roi"]}) + +@app.route("/api/cctv/show_roi", methods=["POST"]) +def api_cctv_show_roi(): + try: + data = request.get_json(force=True) if request.is_json else request.form.to_dict() + except Exception: + data = {} + val = str(data.get("show_roi","true")).lower() in ("1","true","yes","on") + CONFIG["cctv"]["show_roi"] = val + try: + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(CONFIG, f, ensure_ascii=False, indent=2) + except Exception: + pass + return jsonify({"ok": True, "show_roi": CONFIG["cctv"]["show_roi"]}) + +@app.route("/api/roi", methods=["POST"]) +def api_roi(): + data = request.get_json(force=True) + target = data.get("target") + x,y,w,h = data.get("x",0), data.get("y",0), data.get("w",0), data.get("h",0) + if target == "ocr": + CONFIG["ocr"]["roi"] = [x,y,w,h] + elif target == "cctv": + CONFIG["cctv"]["roi"] = [x,y,w,h] + elif target == "cctv2": + CONFIG["cctv"]["roi2"] = [x,y,w,h] + elif target == "cctv_ocr_digits": + CONFIG["cctv_ocr"]["roi_digits"] = [x,y,w,h] + elif target == "cctv_ocr_evidence": + CONFIG["cctv_ocr"]["roi_evidence"] = [x,y,w,h] + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(CONFIG, f, ensure_ascii=False, indent=2) + return jsonify({"ok": True}) + +@app.route("/api/pipeline/") +def api_pipeline(action): + if action == "start": + if not PIPELINE_RUNNING["value"]: + PIPELINE_RUNNING["value"] = True + t = threading.Thread(target=pipeline_loop, daemon=True) + t.start() + THREADS.append(t) + return jsonify({"running": True}) + elif action == "stop": + PIPELINE_RUNNING["value"] = False + return jsonify({"running": False}) + return jsonify({"running": PIPELINE_RUNNING["value"]}) + +def _grab_cctv_ocr_once(): + try: + rtsp = (CONFIG.get("cctv_ocr", {}) or {}).get("rtsp") or "" + if not rtsp: + return None + try: + camera_mgr.ensure_ocr_rtsp(rtsp) + except Exception as _e: + print("WARN: ensure_ocr_rtsp not available:", _e) + for _ in range(3): + f = camera_mgr.grab_frame_from_ocr_source() + if f is not None: + return f + return camera_mgr.grab_frame_from_ocr_source() + except Exception as _e: + print("WARN: _grab_cctv_ocr_once failed:", _e) + ocr_cfg = CONFIG.setdefault("ocr", {}) + prev = ocr_cfg.get("ipcam_rtsp") + try: + ocr_cfg["ipcam_rtsp"] = rtsp + return camera_mgr.grab_frame_from_ocr_source() + finally: + ocr_cfg["ipcam_rtsp"] = prev + + except Exception as _e: + print("WARN: _grab_cctv_ocr_once failed:", _e) + return None + +def gen_mjpeg_frames(source="cctv"): + last = __import__('time').time() + while True: + frame = None + if source == "cctv_ocr": + frame = _grab_cctv_ocr_once() + try: + if frame is not None and (CONFIG.get("cctv_ocr", {}) or {}).get("show_roi"): + rx,ry,rw,rh = tuple((CONFIG.get("cctv_ocr", {}) or {}).get("roi_digits", (0,0,0,0))) + ex,ey,ew,eh = tuple((CONFIG.get("cctv_ocr", {}) or {}).get("roi_evidence", (0,0,0,0))) + rx,ry,rw,rh = _map_roi_to_full(frame.shape[1], frame.shape[0], (rx,ry,rw,rh), STREAM_DOWNSCALE_MAX_WIDTH) + ex,ey,ew,eh = _map_roi_to_full(frame.shape[1], frame.shape[0], (ex,ey,ew,eh), STREAM_DOWNSCALE_MAX_WIDTH) + draw_roi(frame, (rx,ry,rw,rh), (0,0,255)) + draw_roi(frame, (ex,ey,ew,eh), (0,255,255)) + except Exception: + pass + if source == "cctv": + frame = camera_mgr.grab_frame() + if frame is not None and CONFIG["cctv"].get("show_roi"): + x,y,w,h = CONFIG["cctv"].get("roi", [100,100,200,200]) + try: + x,y,w,h = _map_roi_to_full(frame.shape[1], frame.shape[0], (x,y,w,h), STREAM_DOWNSCALE_MAX_WIDTH) + draw_roi(frame, (x,y,w,h), (0,255,0)) + except Exception: + pass + elif source == "ocr": + src = CONFIG["ocr"].get("source") + if src == "window": + frame = win_agent.capture_window_frame() + elif src == "ipcam": + frame = camera_mgr.grab_frame_from_ocr_source() + elif src == "serial": + frame = np.zeros((360, 640, 3), dtype=np.uint8) + try: + val = serial_mgr.get_latest() + port = CONFIG["ocr"].get("serial",{}).get("port","") + txt = f"SERIAL {port}: " + (str(val) if val is not None else "—") + except Exception: + txt = "SERIAL: —" + cv2.putText(frame, txt, (20, 180), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0,255,255), 2) + if frame is not None and CONFIG["ocr"].get("show_roi") and src in ("window","ipcam"): + x,y,w,h = CONFIG["ocr"].get("roi", [100,100,200,200]) + try: + x,y,w,h = _map_roi_to_full(frame.shape[1], frame.shape[0], (x,y,w,h), STREAM_DOWNSCALE_MAX_WIDTH) + draw_roi(frame, (x,y,w,h), (0,0,255)) + except Exception: + pass + if frame is None: + frame = np.zeros((480, 640, 3), dtype=np.uint8) + cv2.putText(frame, "No frame", (30, 40), cv2.FONT_HERSHEY_SIMPLEX, 1, (0,0,255), 2) + try: + h, w = frame.shape[:2] + if w > STREAM_DOWNSCALE_MAX_WIDTH: + ratio = STREAM_DOWNSCALE_MAX_WIDTH / float(w) + frame = cv2.resize(frame, (int(w*ratio), int(h*ratio)), interpolation=cv2.INTER_AREA) + except Exception: + pass + ret, jpeg = cv2.imencode('.jpg', frame, [int(cv2.IMWRITE_JPEG_QUALITY), int(STREAM_JPEG_QUALITY)]) + if not ret: + __import__('time').sleep(STREAM_FRAME_INTERVAL); continue + yield (b'--frame\r\n' + b'Content-Type: image/jpeg\r\n\r\n' + jpeg.tobytes() + b'\r\n') + now = __import__('time').time() + delay = STREAM_FRAME_INTERVAL - (now - last) + if delay > 0: + __import__('time').sleep(delay) + last = now + +@app.route("/stream/cctv") +def stream_cctv(): + headers = {"Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache"} + return Response(gen_mjpeg_frames("cctv"), headers=headers, mimetype='multipart/x-mixed-replace; boundary=frame') + +@app.route("/stream/cctv_ocr") +def stream_cctv_ocr(): + headers = {"Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache"} + return Response(gen_mjpeg_frames("cctv_ocr"), headers=headers, mimetype='multipart/x-mixed-replace; boundary=frame') + +@app.route("/stream/ocr") +def stream_ocr(): + headers = {"Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache"} + return Response(gen_mjpeg_frames("ocr"), headers=headers, mimetype='multipart/x-mixed-replace; boundary=frame') + +@app.route("/stream/") +def stream(source): + if source not in ("cctv","ocr"): + source = "cctv" + return Response(gen_mjpeg_frames(source), headers={'Cache-Control':'no-cache, no-store, must-revalidate','Pragma':'no-cache'}, mimetype='multipart/x-mixed-replace; boundary=frame') + +@app.route("/stream/serial-log") +def stream_serial_log(): + def _gen(): + seq = 0 + backlog = 0 + try: + backlog = int(request.args.get('init', request.args.get('backlog', 50)) or 0) + except Exception: + backlog = 50 + try: + serial_mgr.update_config(CONFIG.get('ocr', {}).get('serial', {})) + except Exception: + pass + try: + last_seq, items0 = serial_mgr.snapshot_log(0) + if items0 and backlog > 0: + for it in items0[-int(backlog):]: + yield f"data: {json.dumps(it, ensure_ascii=False)}\n\n" + seq = last_seq + except Exception: + seq = 0 + + last_value = object() + last_line = object() -def browser_worker(): - # ใช้เบราว์เซอร์จริง (ไม่ headless) แต่จอถูก Xvfb รับไว้จาก xvfb-run - # ระบุ browser="chromium" ให้ตรงกับแพ็กเกจที่ติดตั้ง - with SB(browser="chromium", uc=True, headless=False) as sb: - sb_ref["driver"] = sb - sb.get("https://www.google.com") while True: - url = url_queue.get() try: - print(f"[open] {url}") - sb.get(url) + seq, items = serial_mgr.snapshot_log(seq) + if items: + for it in items: + v = it.get("value", object()) + ln = it.get("line", object()) + + if v == last_value and ln == last_line: + continue + last_value, last_line = v, ln + yield f"data: {json.dumps(it, ensure_ascii=False)}\n\n" time.sleep(0.3) - except Exception as e: - print("[error]", e) - finally: - url_queue.task_done() - -@app.get("/healthz") -def healthz(): - return {"ok": bool(sb_ref.get("driver"))} - -@app.get("/cookies") -def get_cookies(): - sb = sb_ref.get("driver") - if not sb: - raise HTTPException(503, "Browser not ready") - return sb.get_cookies() - -@app.post("/open_url") -async def open_url(request: URLRequest): - if not sb_ref.get("driver"): - raise HTTPException(503, "Browser not ready") - print(f"[queue] {request.url}") - url_queue.put(request.url) - return {"status": "queued", "url": request.url} - -threading.Thread(target=browser_worker, daemon=True).start() + except GeneratorExit: + break + except Exception: + time.sleep(0.5) + headers = {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'} + return Response(_gen(), headers=headers, mimetype='text/event-stream') + +@app.route("/evidence/") +def evidence(filename): + p = resolve_data_file('images', filename) + if os.path.exists(p): + return send_from_directory(os.path.dirname(p), os.path.basename(p)) + p2 = resolve_data_file(filename) + if os.path.exists(p2): + return send_from_directory(os.path.dirname(p2), os.path.basename(p2)) + return ('', 404) + +@app.route("/api/ocr/source", methods=["POST"]) +def api_ocr_source(): + try: + data = request.get_json(force=True) if request.is_json else request.form.to_dict() + except Exception: + data = {} + src = (data.get("source") or "").strip().lower() + if src not in ("window", "ipcam", "serial"): + return jsonify({"ok": False, "error": "invalid source"}), 400 + CONFIG["ocr"]["source"] = src + if src == "ipcam" and data.get("ipcam_rtsp"): + CONFIG["ocr"]["ipcam_rtsp"] = data.get("ipcam_rtsp") + if src == "serial": + payload = data.get("serial") or {} + s_cfg = CONFIG["ocr"].get("serial", {}).copy() + s_cfg.update({ + "port": str(payload.get("port") or s_cfg.get("port","COM4")), + "baudrate": int(payload.get("baudrate") or s_cfg.get("baudrate",1200)), + "bytesize": int(payload.get("bytesize") or s_cfg.get("bytesize",7)), + "parity": str(payload.get("parity") or s_cfg.get("parity","E"))[:1].upper(), + "stopbits": float(payload.get("stopbits") or s_cfg.get("stopbits",1)), + "timeout": float(payload.get("timeout") or s_cfg.get("timeout",1.0)), + "mode": str(payload.get("mode") or s_cfg.get("mode","com")).lower(), + "ip_host": str(payload.get("ip_host") or s_cfg.get("ip_host","127.0.0.1")), + "ip_port": int(payload.get("ip_port") or s_cfg.get("ip_port", 5000)), + "pattern": str(payload.get("pattern") or s_cfg.get("pattern","(\\d+)")), + "set_dtr": (payload.get("set_dtr") if payload.get("set_dtr") is not None else (True if str(payload.get("mode","com")).lower()=="com" else None)), + "set_rts": (payload.get("set_rts") if payload.get("set_rts") is not None else (True if str(payload.get("mode","com")).lower()=="com" else None)) +}) + CONFIG['ocr']['serial'] = s_cfg + + CONFIG["ocr"]["serial"] = s_cfg + try: + serial_mgr.update_config(s_cfg) + except Exception: + pass + CONFIG["ocr"]["ipcam_rtsp"] = data.get("ipcam_rtsp") + try: + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(CONFIG, f, ensure_ascii=False, indent=2) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + return jsonify({"ok": True, "source": CONFIG["ocr"]["source"]}) + +@app.route("/api/ocr/window", methods=["POST"]) +def api_ocr_window(): + try: + data = request.get_json(force=True) if request.is_json else request.form.to_dict() + except Exception: + data = {} + title = (data.get("window_title") or "").strip() + CONFIG["ocr"]["window_title"] = title + set_source = data.get("set_source_window") + if set_source in (None, "", True, "true", "1", 1): + CONFIG["ocr"]["source"] = "window" + try: + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(CONFIG, f, ensure_ascii=False, indent=2) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + return jsonify({"ok": True, "window_title": CONFIG["ocr"]["window_title"], "source": CONFIG["ocr"]["source"]}) + +def _ts_now(): + try: + return datetime.now().isoformat(timespec="seconds") + except Exception: + return datetime.now().isoformat() + +@app.route("/api/cctv/status") +def api_cctv_status(): + ok = True + details = {} + try: + frame = camera_mgr.grab_frame() + got = frame is not None + details = { + "enabled": bool(CONFIG.get("cctv",{}).get("enabled", False)), + "rtsp": CONFIG.get("cctv",{}).get("rtsp", ""), + "active_rtsp": (getattr(getattr(camera_mgr, "cctv_thread", None), "rtsp", None)), + "connected": bool(got), + "tested_at": _ts_now() + } + except Exception as e: + ok = False + details["error"] = str(e) + return jsonify({"ok": ok, "cctv": details}) + +@app.route("/api/cctv/test", methods=["POST","GET"]) +def api_cctv_test(): + try: + frame = camera_mgr.grab_frame() + return jsonify({"ok": frame is not None}) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + +@app.route("/api/com/status") +def api_com_status(): + try: + source = (CONFIG.get("ocr",{}).get("source") or "").lower() + except Exception: + source = "" + is_com = (source == "serial") + s_cfg = CONFIG.get("ocr",{}).get("serial",{}) if is_com else {} + status = { + "mode": source, + "is_com_mode": is_com, + "port": (s_cfg.get("port")) if is_com else None, + "serial_mode": (s_cfg.get("mode") or "com") if is_com else None, + "ip_host": (s_cfg.get("ip_host")) if is_com else None, + "ip_port": (s_cfg.get("ip_port")) if is_com else None, + "connection": ("online" if (s_cfg.get("mode") or "com")=="ip" else "internal") if is_com else None, + "stable_seconds": (CONFIG.get("ocr",{}).get("settle_seconds")) if is_com else None, + "latest_value": None, + "stable": None, + "timestamp": _ts_now() + } + ok = True + try: + if is_com: + snap = serial_mgr.get_snapshot() + sval = snap["value"] + last_change = snap["last_change"] + status["latest_value"] = sval + status["stable"] = bool(serial_mgr.is_stable(CONFIG["ocr"]["settle_seconds"])) + etag = f'W/"{last_change:.6f}"' + inm = request.headers.get("If-None-Match") + if inm == etag: + resp = jsonify({"ok": True}) + resp.status_code = 304 + resp.set_data(b"") + return resp + resp = jsonify({"ok": ok, "com": status}) + resp.headers["ETag"] = etag + return resp + except Exception as e: + ok = False + status["error"] = str(e) + return jsonify({"ok": ok, "com": status}) + +@app.route("/api/cctv/lines", methods=["GET","POST","DELETE"]) +def api_cctv_lines(): + global CONFIG + if request.method == "GET": + data = {"hub": CONFIG.get("cctv", {}).get("line_hub", [-1,-1]), "lines": CONFIG.get("cctv", {}).get("lines", [])} + return jsonify({"ok": True, **data}) + if request.method == "POST": + try: + payload = request.get_json(silent=True) or {} + except Exception: + payload = {} + hub = payload.get("hub") + lines = payload.get("lines") + if not isinstance(hub, list) or len(hub) != 2: + return jsonify({"ok": False, "error": "invalid hub"}), 400 + if not isinstance(lines, list): + return jsonify({"ok": False, "error": "invalid lines"}), 400 + CONFIG.setdefault("cctv", {}) + CONFIG["cctv"]["line_hub"] = [int(hub[0]), int(hub[1])] + clean_lines = [] + for it in lines: + if isinstance(it, list) and len(it) == 2: + clean_lines.append([int(it[0]), int(it[1])]) + elif isinstance(it, dict) and "to" in it: + to = it["to"] + if isinstance(to, list) and len(to) == 2: + clean_lines.append([int(to[0]), int(to[1])]) + CONFIG["cctv"]["lines"] = clean_lines + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(CONFIG, f, ensure_ascii=False, indent=2) + return jsonify({"ok": True, "hub": CONFIG["cctv"]["line_hub"], "lines": CONFIG["cctv"]["lines"]}) + if request.method == "DELETE": + CONFIG.setdefault("cctv", {}) + CONFIG["cctv"]["line_hub"] = [-1, -1] + CONFIG["cctv"]["lines"] = [] + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(CONFIG, f, ensure_ascii=False, indent=2) + return jsonify({"ok": True}) + +@app.route("/events/logs") +def events_logs(): + log_event("sse", "client connected") + def gen(): + q = subscribe_logs() + try: + while True: + try: + data = q.get(timeout=20) + yield f"data: {data}\n\n" + except _q.Empty: + yield "event: ping\ndata: -\n\n" + finally: + unsubscribe_logs(q) + return Response(gen(), mimetype="text/event-stream", headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}) + +@app.route("/events/selftest") +def events_selftest(): + def gen(): + for i in range(1, 6): + yield f"data: {{\"tick\": {i}}}\n\n" + time.sleep(1) + return Response(gen(), mimetype="text/event-stream", headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}) + +@app.after_request +def no_cache(resp): + try: + if getattr(resp, 'mimetype', None) == 'text/html': + resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' + except Exception: + pass + return resp + +@app.get('/images//crop') +def api_crop_image(image_id=None, purpose_override=None): + purpose = purpose_override or request.args.get('purpose') + qx = request.args.get('x', type=float) + qy = request.args.get('y', type=float) + qw = request.args.get('w', type=float) + qh = request.args.get('h', type=float) + unit = request.args.get('unit', 'rel') + + rows = db_query("SELECT * FROM images WHERE id=?", (image_id,)) + if not rows: + return ('Not found', 404) + img = rows[0] + if not os.path.exists(img['path']): + return ('file missing', 404) + + with Image.open(img['path']) as im: + W, H = im.size + if purpose: + crop = db_query("SELECT * FROM image_crops WHERE image_id=? AND purpose=? ORDER BY id DESC LIMIT 1", (image_id, purpose)) + if not crop: + x = 0; y = 0; w = 1; h = 1 + unit = 'rel' + else: + c = crop[0] + x = c['x_rel'] * W; y = c['y_rel'] * H; w = c['w_rel'] * W; h = c['h_rel'] * H + unit = 'px' + else: + if unit == 'px': + x,y,w,h = qx or 0, qy or 0, qw or W, qh or H + else: + x = (qx or 0)*W; y = (qy or 0)*H; w = (qw or 1)*W; h = (qh or 1)*H + + x = max(0, min(W-1, int(x))) + y = max(0, min(H-1, int(y))) + w = max(1, min(W-x, int(w))) + h = max(1, min(H-y, int(h))) + box = (x, y, x+w, y+h) + out = im.crop(box) + buf = io.BytesIO() + out.save(buf, format='JPEG', quality=90) + buf.seek(0) + resp = Response(buf.getvalue(), mimetype='image/jpeg') + resp.headers['Cache-Control'] = 'public, max-age=3600' + return resp + +if __name__ == "__main__": + print(f" * Weighbridge Assistant running on http://localhost:{APP_PORT}") + try: + app.run(host=APP_HOST, port=APP_PORT, debug=False, threaded=True) + finally: + webrtc_mgr.shutdown() \ No newline at end of file