From 1ee85fb402cb2667e80d7c68775de39c37890eb6 Mon Sep 17 00:00:00 2001 From: "DESKTOP-064TTA1\\Fai LUK" Date: Thu, 9 Apr 2026 22:52:38 +0800 Subject: [PATCH] no message --- python/Bag3.py | 507 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 443 insertions(+), 64 deletions(-) diff --git a/python/Bag3.py b/python/Bag3.py index 96114cd..c59498d 100644 --- a/python/Bag3.py +++ b/python/Bag3.py @@ -197,6 +197,21 @@ PRINTER_CHECK_MS = 60 * 1000 # 1 minute when printer OK PRINTER_RETRY_MS = 30 * 1000 # 30 seconds when printer failed PRINTER_SOCKET_TIMEOUT = 3 DATAFLEX_SEND_TIMEOUT = 10 # seconds when sending ZPL to DataFlex +# Gap between bag labels on DataFlex (one TCP session per batch; tune if bags are skipped) +DATAFLEX_INTER_LABEL_DELAY_SEC = 2.5 +# After ~JA/~RO/~JR recovery, let firmware finish before sending label ZPL (avoids stuck E1005) +DATAFLEX_POST_RECOVERY_DELAY_SEC = 1.5 +# Zebra ~RO only (used when FPSMS_DATAFLEX_NO_JR is set) +DATAFLEX_RESET_BYTES = b"~RO1\r\n~RO2\r\n" +# Full host recovery: ~JA clear buffers, ~RO counters, ~JR soft reset (clears latched errors without power cycle) +DATAFLEX_FULL_RECOVERY_BYTES = b"~JA\r\n~RO1\r\n~RO2\r\n~JR\r\n" + + +def _dataflex_recovery_payload() -> bytes: + """~JA+~RO+~JR by default; set env FPSMS_DATAFLEX_NO_JR=1 to skip ~JR if firmware rejects it.""" + if os.environ.get("FPSMS_DATAFLEX_NO_JR", "").strip().lower() in ("1", "true", "yes"): + return b"~JA\r\n" + DATAFLEX_RESET_BYTES + return DATAFLEX_FULL_RECOVERY_BYTES def _zpl_escape(s: str) -> str: @@ -218,7 +233,7 @@ def generate_zpl_dataflex( Row 1 (from zero): QR code, then item name (rotated 90°). Row 2: Batch/lot (left), item code (right). Label and QR use lotNo from API when present, else batch_no (Bxxxxx). - Job counter reset (~RO) is sent separately via [send_dataflex_job_counter_reset] before labels. + Host recovery (~JA/~RO/~JR) is sent separately via [send_dataflex_job_counter_reset] before labels. """ desc = _zpl_escape((item_name or "—").strip()) code = _zpl_escape((item_code or "—").strip()) @@ -230,7 +245,10 @@ def generate_zpl_dataflex( else: qr_payload = label_line if label_line else batch_no.strip() qr_value = _zpl_escape(qr_payload) + # Explicit ^PQ1: each ^XA…^XZ is exactly one bag. Avoids E1005 "over quantity" on some Zebra/DataFlex + # firmware when many labels are sent on one TCP session without a per-job quantity. return f"""^XA +^PQ1,0,1,N ^CI28 ^PW700 ^LL500 @@ -248,19 +266,62 @@ def generate_zpl_dataflex( def send_dataflex_job_counter_reset(ip: str, port: int) -> None: """ - Reset printer job / label counters when switching to a new job (new row). + Prepare DataFlex for a new job without power cycling: ~JA (clear host buffer / cancel pending), + ~RO1/~RO2 (counters), ~JR (soft reset — clears latched E1005 / “over qty” on many Zebra-class units). + + Brief pause after send so ~JR can finish before label data. Set FPSMS_DATAFLEX_NO_JR=1 to omit ~JR. + """ + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + except OSError: + pass + sock.settimeout(DATAFLEX_SEND_TIMEOUT) + try: + sock.connect((ip, port)) + sock.sendall(_dataflex_recovery_payload()) + time.sleep(DATAFLEX_POST_RECOVERY_DELAY_SEC) + try: + sock.shutdown(socket.SHUT_WR) + except OSError: + pass + finally: + sock.close() - Zebra: ~RO1 / ~RO2 reset counters 1 and 2. Sent as a *standalone* TCP write *before* any ^XA…^XZ - label, because many firmwares ignore ~ commands inside a format block. - Uses CRLF line endings (common Zebra expectation). Raises on TCP error like [send_zpl_to_dataflex]. +def send_dataflex_reset_and_labels( + ip: str, + port: int, + zpl: str, + copies: int, + delay_sec: float, +) -> None: + """ + One TCP connection: send ~RO reset, then `copies` identical ZPL labels with delay_sec between + copies (not after the last). Avoids rapid connect/disconnect per bag, which can cause skipped + prints on some DataFlex/Zebra TCP hosts. """ - raw = "~RO1\r\n~RO2\r\n".encode("ascii") + if copies < 1: + return + raw_zpl = zpl.encode("utf-8") sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + except OSError: + pass sock.settimeout(DATAFLEX_SEND_TIMEOUT) try: sock.connect((ip, port)) - sock.sendall(raw) + sock.sendall(_dataflex_recovery_payload()) + time.sleep(DATAFLEX_POST_RECOVERY_DELAY_SEC) + for i in range(copies): + sock.sendall(raw_zpl) + if i < copies - 1: + time.sleep(delay_sec) + try: + sock.shutdown(socket.SHUT_WR) + except OSError: + pass finally: sock.close() @@ -502,6 +563,35 @@ def _image_to_zpl_gfa(pil_image: "Image.Image") -> str: ^XZ""" +def zpl_apply_print_quantity(zpl: str, copies: int) -> str: + """ + Ask the printer to output `copies` identical labels from one ZPL format by inserting ^PQ after ^XA. + Results in a **single** spool job / one write — no N separate Windows jobs, no chained ^XA blocks + (which broke some TSC drivers with white-on-white ^GFA output). + """ + if copies <= 1: + return zpl + first_fmt = zpl.split("^XZ", 1)[0] if "^XZ" in zpl else zpl + if "^PQ" in first_fmt.upper(): + return zpl + lines = zpl.splitlines() + new_lines: list[str] = [] + inserted = False + for line in lines: + new_lines.append(line) + if not inserted and line.strip() == "^XA": + # ZPL II: q labels, 0 pause between, 1 replicate (non-serial), N = default options. + # Same graphic (^GFA) is repeated q times — e.g. 需求數量 150 → 150 identical labels, one spool job. + new_lines.append(f"^PQ{copies},0,1,N") + inserted = True + if not inserted: + raise RuntimeError( + "標籤 ZPL 無法插入 ^PQ(格式非預期)。請聯絡程式維護。" + ) + ending = "\n" if zpl.endswith("\n") else "" + return "\n".join(new_lines) + ending + + def send_image_to_label_printer(printer_name: str, pil_image: "Image.Image") -> None: """ Send a PIL Image to 標簽機 via Windows GDI (so Chinese and graphics print correctly). @@ -571,10 +661,18 @@ def send_image_to_label_printer(printer_name: str, pil_image: "Image.Image") -> def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None: """Send ZPL label (^XA…^XZ) to DataFlex printer via TCP. Raises on connection/send error.""" sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + except OSError: + pass sock.settimeout(DATAFLEX_SEND_TIMEOUT) try: sock.connect((ip, port)) sock.sendall(zpl.encode("utf-8")) + try: + sock.shutdown(socket.SHUT_WR) + except OSError: + pass finally: sock.close() @@ -619,6 +717,18 @@ def send_zpl_to_label_printer(target: str, zpl: str) -> None: ser.close() +def send_zpl_to_label_printer_batch(target: str, zpl: str, copies: int) -> None: + """ + Print multiple identical ZPL labels in **exactly one** Windows spool job (or one COM write). + + Uses ZPL ^PQ so the printer firmware repeats the format N times — never one job per label. + """ + if copies < 1: + return + zpl_out = zpl_apply_print_quantity(zpl, copies) + send_zpl_to_label_printer(target, zpl_out) + + def load_laser_last_count() -> tuple[int, Optional[str]]: """Load last batch count and date from laser counter file. Returns (count, date_str).""" if not os.path.exists(LASER_COUNTER_FILE): @@ -810,6 +920,7 @@ def run_laser_row_send_thread( After success, POST LASER qty to API when job_order_id and base_url are set. """ if laser_busy_ref[0]: + messagebox.showwarning("激光機", "請等待目前激光發送完成。") return laser_busy_ref[0] = True @@ -864,6 +975,163 @@ def run_laser_row_send_thread( threading.Thread(target=worker, daemon=True).start() +def run_dataflex_fixed_qty_thread( + root: tk.Tk, + dataflex_lock: threading.Lock, + dataflex_busy_ref: list, + ip: str, + port: int, + n: int, + zpl: str, + label_text: str, + jo_id: Optional[int], + base_url: str, + set_status_message: Callable[[str, bool], None], + on_recorded: Callable[[], None], +) -> None: + """ + Send n DataFlex labels with delay between copies. Runs off the Tk main thread so the UI + stays responsive (printer dropdown, other controls) during printing. + """ + def worker() -> None: + with dataflex_lock: + if dataflex_busy_ref[0]: + root.after( + 0, + lambda: messagebox.showwarning( + "打袋機", + "請等待目前列印完成或先停止連續列印。", + ), + ) + return + dataflex_busy_ref[0] = True + try: + send_dataflex_reset_and_labels( + ip, + port, + zpl, + n, + DATAFLEX_INTER_LABEL_DELAY_SEC, + ) + root.after( + 0, + lambda: set_status_message( + f"已送出列印:批次 {label_text} x {n} 張", + is_error=False, + ), + ) + if jo_id is not None: + try: + submit_job_order_print_submit(base_url, int(jo_id), n, "DATAFLEX") + root.after(0, on_recorded) + except requests.RequestException as ex: + root.after( + 0, + lambda err=str(ex): messagebox.showwarning( + "打袋機", + f"已送出 {n} 張,但伺服器記錄失敗:{err}", + ), + ) + except ConnectionRefusedError: + root.after( + 0, + lambda: set_status_message( + f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。", + is_error=True, + ), + ) + except socket.timeout: + root.after( + 0, + lambda: set_status_message( + f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。", + is_error=True, + ), + ) + except OSError as err: + root.after( + 0, + lambda e=err: set_status_message(f"列印失敗:{e}", is_error=True), + ) + finally: + with dataflex_lock: + dataflex_busy_ref[0] = False + + threading.Thread(target=worker, daemon=True).start() + + +def run_label_print_batch_thread( + root: tk.Tk, + label_lock: threading.Lock, + label_busy_ref: list, + com: str, + zpl_img: str, + n: int, + jo_id: Optional[int], + base_url: str, + set_status_message: Callable[[str, bool], None], + on_recorded: Callable[[], None], +) -> None: + """ + Send n label copies off the main thread so DataFlex / laser / UI stay usable in parallel. + """ + def worker() -> None: + with label_lock: + if label_busy_ref[0]: + root.after( + 0, + lambda: messagebox.showwarning( + "標籤機", + "請等待目前標籤列印完成。", + ), + ) + return + label_busy_ref[0] = True + try: + send_zpl_to_label_printer_batch(com, zpl_img, n) + root.after( + 0, + lambda: set_status_message(f"已送出列印:標籤 x {n} 張", is_error=False), + ) + if jo_id is not None: + try: + submit_job_order_print_submit(base_url, int(jo_id), n, "LABEL") + root.after(0, on_recorded) + root.after( + 0, + lambda: messagebox.showinfo( + "標籤機", + f"已送出列印:{n} 張標籤(已記錄)", + ), + ) + except requests.RequestException as ex: + root.after( + 0, + lambda err=str(ex): messagebox.showwarning( + "標籤機", + f"標籤已列印 {n} 張,但伺服器記錄失敗:{err}", + ), + ) + else: + root.after( + 0, + lambda: messagebox.showwarning( + "標籤機", + f"已送出列印:{n} 張標籤(無工單 id,無法寫入伺服器記錄)", + ), + ) + except Exception as err: + root.after( + 0, + lambda e=str(err): messagebox.showerror("標籤機", f"列印失敗:{e}"), + ) + finally: + with label_lock: + label_busy_ref[0] = False + + threading.Thread(target=worker, daemon=True).start() + + def _printed_qty_int(raw) -> int: """Parse API printed qty field (may be float JSON) to int.""" try: @@ -966,7 +1234,8 @@ def on_job_order_click(jo: dict, batch: str) -> None: def ask_label_count(parent: tk.Tk) -> Optional[int]: """ When printer is 標簽機, ask how many labels to print: - optional direct qty in text field, +50/+10/+5/+1, 重置, then 確認送出. + optional direct qty in text field (e.g. 150), +50/+10/+5/+1, 重置, then 確認送出. + That count becomes ZPL ^PQ in one job — 150 → 150 identical labels. Returns count (>= 1), or None if cancelled. """ result: list[Optional[int]] = [None] @@ -1148,28 +1417,69 @@ def _sleep_interruptible(stop_event: threading.Event, total_sec: float) -> None: time.sleep(min(0.05, remaining)) -def open_dataflex_stop_window(parent: tk.Tk, stop_event: threading.Event) -> tk.Toplevel: - """Small window with 停止列印 for DataFlex continuous mode (non-modal so stop stays usable).""" +def open_dataflex_stop_window( + parent: tk.Tk, + stop_event: threading.Event, + stop_win_ref: list, +) -> tk.Toplevel: + """ + Small window with 停止列印 for DataFlex continuous mode (non-modal so stop stays usable). + + Stays above other dialogs (e.g. 標籤機 quantity) via periodic lift + optional topmost on Windows, + so switching printer and printing labels does not hide the stop control. Ref is cleared on destroy. + """ win = tk.Toplevel(parent) win.title("打袋機連續列印") win.geometry("420x170") - win.transient(parent) + # On Windows, transient(root) can hide this Toplevel when the menubutton / printer row + # updates (e.g. switching to 激光機); keep transient only on non-Windows. + if os.name != "nt": + win.transient(parent) win.configure(bg=BG_TOP) + stop_win_ref[0] = win + if os.name == "nt": + try: + win.attributes("-topmost", True) + except tk.TclError: + pass + tk.Label( win, - text="連續列印進行中,可隨時按下方停止。", + text="連續列印進行中(與上方列印機選項無關),可隨時按下方停止。", font=get_font(FONT_SIZE), bg=BG_TOP, wraplength=400, ).pack(pady=(16, 8)) + def clear_topmost() -> None: + if os.name == "nt": + try: + win.attributes("-topmost", False) + except tk.TclError: + pass + def stop() -> None: stop_event.set() + stop_win_ref[0] = None + clear_topmost() try: win.destroy() except tk.TclError: pass + def periodic_lift() -> None: + if stop_win_ref[0] is not win: + return + try: + if not win.winfo_exists(): + return + win.lift() + if os.name == "nt": + win.attributes("-topmost", True) + except tk.TclError: + return + parent.after(4000, periodic_lift) + tk.Button( win, text="停止列印", @@ -1181,6 +1491,7 @@ def open_dataflex_stop_window(parent: tk.Tk, stop_event: threading.Event) -> tk. pady=10, ).pack(pady=12) win.protocol("WM_DELETE_WINDOW", stop) + parent.after(500, periodic_lift) return win @@ -1240,6 +1551,27 @@ def main() -> None: # Laser: keep connection open for repeated sends; close when switching away laser_conn_ref: list = [None] laser_send_busy_ref: list = [False] + # DataFlex: shared lock so fixed-qty and continuous jobs do not overlap (independent of laser/label) + dataflex_lock = threading.Lock() + dataflex_busy_ref: list = [False] + # 標籤機: own lock so label jobs do not overlap; does not block DataFlex or laser + label_lock = threading.Lock() + label_busy_ref: list = [False] + # DataFlex continuous: stop Toplevel ref so we can lift it after other dialogs + dataflex_stop_win_ref: list = [None] + + def lift_dataflex_stop_if_running() -> None: + """After closing another dialog (e.g. 標籤印數), bring the stop panel forward again.""" + w = dataflex_stop_win_ref[0] + if w is None: + return + try: + if w.winfo_exists(): + w.lift() + if os.name == "nt": + w.attributes("-topmost", True) + except tk.TclError: + pass # Top: left [前一天] [date] [後一天] | right [printer dropdown] top = tk.Frame(root, padx=12, pady=12, bg=BG_TOP) @@ -1291,6 +1623,44 @@ def main() -> None: ttk.Button(right_frame, text="設定", command=lambda: open_setup_window(root, settings, base_url_ref)).pack( side=tk.LEFT, padx=(0, 12) ) + + def on_dataflex_host_reset() -> None: + """Send ~JA/~RO/~JR to clear E1005 latch without turning the printer off.""" + ip = (settings.get("dabag_ip") or "").strip() + port_str = (settings.get("dabag_port") or "3008").strip() + if not ip: + messagebox.showwarning("打袋機", "請先在「設定」填寫打袋機 IP。") + return + try: + port = int(port_str) + except ValueError: + port = 3008 + + def worker() -> None: + try: + send_dataflex_job_counter_reset(ip, port) + root.after( + 0, + lambda: messagebox.showinfo( + "打袋機", + "已送出主機重設(緩衝清除/計數/軟重設)。\n" + "若畫面仍顯示 E1005,請再按一次或關機重開。", + ), + ) + except OSError as ex: + root.after( + 0, + lambda e=str(ex): messagebox.showerror( + "打袋機", + f"連線失敗,無法重設:{e}", + ), + ) + + threading.Thread(target=worker, daemon=True).start() + + ttk.Button(right_frame, text="打袋重設", command=on_dataflex_host_reset).pack( + side=tk.LEFT, padx=(0, 8) + ) # 列印機 label: green when printer connected, red when not (checked periodically) printer_status_lbl = tk.Label( right_frame, @@ -1350,6 +1720,8 @@ def main() -> None: except Exception: pass laser_conn_ref[0] = None + # DataFlex continuous stop panel can drop behind after OptionMenu closes; re-lift for any choice. + root.after(100, lift_dataflex_stop_if_running) printer_var.trace_add("write", on_printer_selection_changed) @@ -1638,17 +2010,35 @@ def main() -> None: label_text = (lot_no or b).strip() if continuous: stop_ev = threading.Event() - stop_win = open_dataflex_stop_window(root, stop_ev) + stop_win = open_dataflex_stop_window( + root, stop_ev, dataflex_stop_win_ref + ) def dflex_worker() -> None: + with dataflex_lock: + if dataflex_busy_ref[0]: + root.after( + 0, + lambda: messagebox.showwarning( + "打袋機", + "請等待目前列印完成或先停止連續列印。", + ), + ) + return + dataflex_busy_ref[0] = True printed = 0 error_shown = False try: + # One TCP job per bag (not one endless stream). Persistent socket + # caused E1005 over-qty on some DataFlex units after a few labels. send_dataflex_job_counter_reset(ip, port) while not stop_ev.is_set(): send_zpl_to_dataflex(ip, port, zpl) printed += 1 - _sleep_interruptible(stop_ev, 2.0) + _sleep_interruptible( + stop_ev, + DATAFLEX_INTER_LABEL_DELAY_SEC, + ) except ConnectionRefusedError: error_shown = True root.after( @@ -1677,8 +2067,16 @@ def main() -> None: ), ) finally: + with dataflex_lock: + dataflex_busy_ref[0] = False def _done() -> None: + dataflex_stop_win_ref[0] = None + try: + if os.name == "nt": + stop_win.attributes("-topmost", False) + except tk.TclError: + pass try: stop_win.destroy() except tk.TclError: @@ -1713,37 +2111,29 @@ def main() -> None: threading.Thread(target=dflex_worker, daemon=True).start() else: - try: - send_dataflex_job_counter_reset(ip, port) - for i in range(n): - send_zpl_to_dataflex(ip, port, zpl) - if i < n - 1: - time.sleep(2) - set_status_message(f"已送出列印:批次 {label_text} x {n} 張", is_error=False) - jo_id = j.get("id") - if jo_id is not None: - try: - submit_job_order_print_submit( - base_url_ref[0], int(jo_id), n, "DATAFLEX" - ) - load_job_orders(from_user_date_change=False) - except requests.RequestException as ex: - messagebox.showwarning( - "打袋機", - f"已送出 {n} 張,但伺服器記錄失敗:{ex}", - ) - except ConnectionRefusedError: - set_status_message(f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。", is_error=True) - except socket.timeout: - set_status_message(f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。", is_error=True) - except OSError as err: - set_status_message(f"列印失敗:{err}", is_error=True) + run_dataflex_fixed_qty_thread( + root=root, + dataflex_lock=dataflex_lock, + dataflex_busy_ref=dataflex_busy_ref, + ip=ip, + port=port, + n=n, + zpl=zpl, + label_text=label_text, + jo_id=j.get("id"), + base_url=base_url_ref[0], + set_status_message=set_status_message, + on_recorded=lambda: load_job_orders( + from_user_date_change=False + ), + ) elif printer_var.get() == "標簽機": com = (settings.get("label_com") or "").strip() if not com: messagebox.showerror("標簽機", "請在設定中填寫標簽機名稱 (例如:TSC TTP-246M Pro)。") else: count = ask_label_count(root) + lift_dataflex_stop_if_running() if count is not None: item_code = j.get("itemCode") or "—" item_name = j.get("itemName") or "—" @@ -1762,31 +2152,20 @@ def main() -> None: lot_no=lot_no, ) zpl_img = _image_to_zpl_gfa(label_img) - for i in range(n): - send_zpl_to_label_printer(com, zpl_img) - if i < n - 1: - time.sleep(0.5) - jo_id = j.get("id") - if jo_id is not None: - try: - submit_job_order_print_submit( - base_url_ref[0], int(jo_id), n, "LABEL" - ) - load_job_orders(from_user_date_change=False) - messagebox.showinfo( - "標籤機", - f"已送出列印:{n} 張標籤(已記錄)", - ) - except requests.RequestException as ex: - messagebox.showwarning( - "標籤機", - f"標籤已列印 {n} 張,但伺服器記錄失敗:{ex}", - ) - else: - messagebox.showwarning( - "標籤機", - f"已送出列印:{n} 張標籤(無工單 id,無法寫入伺服器記錄)", - ) + run_label_print_batch_thread( + root=root, + label_lock=label_lock, + label_busy_ref=label_busy_ref, + com=com, + zpl_img=zpl_img, + n=n, + jo_id=j.get("id"), + base_url=base_url_ref[0], + set_status_message=set_status_message, + on_recorded=lambda: load_job_orders( + from_user_date_change=False + ), + ) except Exception as err: messagebox.showerror("標簽機", f"列印失敗:{err}") elif printer_var.get() == "激光機":