#!/usr/bin/env python3 """ zorros_print_agent.py — Local Kitchen Print Bridge Run this on ANY computer at Zorro's on the same WiFi as the printer. Polls Railway every 10 seconds for new orders → prints ESC/POS tickets directly to 192.168.20.68:9100 Usage: pip install requests export PRINT_BRIDGE_TOKEN=your-token export PRINTER_IP=192.168.20.68 # optional; this is the default export BACKUP_PRINTER_IP=192.168.213.110 # optional 2nd printer; used if primary is down export RECEIPT_PRINTER_IP=192.168.20.60 # optional receipt station; customer/store/driver copies print there python3 zorros_print_agent.py --status # no-token readiness check python3 zorros_print_agent.py --test-print # local printer test python3 zorros_print_agent.py --test-bridge-print # Railway + token + printer test python3 zorros_print_agent.py --doctor # no-paper readiness check python3 zorros_print_agent.py --probe-completion # certify GS ( H support (prints 1 slip) python3 zorros_print_agent.py # start the live spooler """ import time, socket, datetime, os, sys, requests try: import fcntl except ImportError: # pragma: no cover - store agent runs on macOS/Linux fcntl = None try: from dotenv import load_dotenv load_dotenv() except ImportError: pass RAILWAY_URL = os.getenv("RAILWAY_URL", "https://zorro-phone-agent-production.up.railway.app").rstrip("/") PRINTER_IP = os.getenv("PRINTER_IP", "192.168.20.68").strip() PRINTER_PORT = int(os.getenv("PRINTER_PORT", "9100")) # Optional second printer on the same LAN. When set, a ticket that fails to # reach the primary is retried on the backup before the order is left for the # next poll — so a single jammed/offline printer doesn't stall the whole line. BACKUP_PRINTER_IP = os.getenv("BACKUP_PRINTER_IP", "").strip() BACKUP_PRINTER_PORT = int(os.getenv("BACKUP_PRINTER_PORT", str(PRINTER_PORT))) # Optional receipt-station printer (owner station split 2026-08-02: kitchen # ticket stays on the primary; CUSTOMER/STORE/DRIVER copies go here). When # unset, or when this printer is down, copies print on the primary as before — # a dead receipt printer must never cost the cashier their PAID/COLLECT copy. RECEIPT_PRINTER_IP = os.getenv("RECEIPT_PRINTER_IP", "").strip() RECEIPT_PRINTER_PORT = int(os.getenv("RECEIPT_PRINTER_PORT", str(PRINTER_PORT))) POLL_INTERVAL = int(os.getenv("PRINT_POLL_INTERVAL", "10")) PRINT_BRIDGE_TOKEN = os.getenv("PRINT_BRIDGE_TOKEN", "").strip() # Real-time printer status gate. TCP-send success does NOT mean a ticket printed: # a powered, networked printer that is out of paper or has its cover open accepts # the bytes and silently drops the job. When enabled (default), we ask the printer # whether it can actually print (ESC/POS DLE EOT) before marking an order printed. # Set PRINTER_STATUS_CHECK=0 to disable if a printer misbehaves in the store. STATUS_CHECK_ENABLED = os.getenv("PRINTER_STATUS_CHECK", "1").strip().lower() not in ("0", "false", "no", "off") # Post-send STATUS SAMPLE. socket.sendall() only proves the OS accepted the # bytes into the LOCAL send buffer — over the store VPN the tunnel can drop # right after sendall returns and the ticket never reaches the printer while # the order still gets marked printed (order #59, 2026-07-26 18:25Z). The # pre-send status gate above cannot catch this: the connection was healthy # when it ran. So after the ticket bytes we request real-time status ON THE # SAME connection. IMPORTANT LIMITS (Epson data-processing reference): DLE EOT # is a REAL-TIME command — the printer answers it as soon as it is received, # while the ticket bytes may still sit unprocessed in its receive/print # buffers. A reply is therefore evidence the link survived past the send plus # a sample of current printer status — NOT proof the ticket was parsed or # physically printed. Silence from a printer that is known to answer DLE EOT # means the link likely died mid-stream: the ticket enters a bounded # sent-unconfirmed retry (below) instead of being marked printed. # Set PRINT_SEND_STATUS_CHECK=0 to disable if a printer misbehaves. SEND_STATUS_CHECK_ENABLED = os.getenv("PRINT_SEND_STATUS_CHECK", "1").strip().lower() not in ("0", "false", "no", "off") SEND_STATUS_TIMEOUT = float(os.getenv("PRINT_SEND_STATUS_TIMEOUT", "5")) # A sent-but-unconfirmed ticket retries at most this many times, then is HELD # (not marked printed, staff paged) — bounded so a printer that prints fine # but whose replies get lost cannot spew duplicate tickets every poll forever. MAX_UNCONFIRMED_SENDS = int(os.getenv("PRINT_MAX_UNCONFIRMED_SENDS", "3")) # TRUE job-completion barrier (ESC/POS GS ( H fn=48 "process ID response"): # unlike real-time DLE EOT, GS ( H is an ordinary buffered command — the # printer transmits the echoed process ID only AFTER all receive/print data # before it has been processed, so its reply DOES confirm the ticket was # consumed. OFF by default: BTP-M300A support is UNCERTIFIED, and a printer # that does not know the command may print the trailing ID bytes as garbage # on a live kitchen ticket. Certify with --probe-completion (prints one small # slip; run owner-approved, in a quiet window) before setting # PRINT_COMPLETION_CHECK=1. COMPLETION_CHECK_ENABLED = os.getenv("PRINT_COMPLETION_CHECK", "0").strip().lower() in ("1", "true", "yes", "on") COMPLETION_TIMEOUT = float(os.getenv("PRINT_COMPLETION_TIMEOUT", "15")) # Cash-drawer kick (owner spec 2026-07-24, drawer control): the drawer plugs # into the receipt printer's DK port; an ESC p pulse pops it. OFF by default — # enable per store with DRAWER_KICK_ENABLED=1 once a drawer is physically # attached, else the pulse harmlessly no-ops on drawerless printers. DRAWER_KICK_ENABLED = os.getenv("DRAWER_KICK_ENABLED", "").strip().lower() in ("1", "true", "yes", "on") SPOOLER_VERSION = "2026-08-08-ticket-dedup-v5" SPOOLER_LOCK_PATH = os.getenv( "PRINT_SPOOLER_LOCK_PATH", "/tmp/zorros_print_agent.lock" ).strip() ESC=b'\x1b'; GS=b'\x1d'; DLE=b'\x10'; EOT=b'\x04' INIT=ESC+b'@'; CUT=GS+b'V\x41\x05' # Same pin-2 pulse backend/printer_client.py uses for its owner-gated kick. OPEN_DRAWER=ESC+b'p\x00\x19\xfa' BOLD_ON=ESC+b'E\x01'; BOLD_OFF=ESC+b'E\x00' ALIGN_CTR=ESC+b'a\x01'; ALIGN_LEFT=ESC+b'a\x00' DOUBLE_ON=GS+b'!\x11'; DOUBLE_OFF=GS+b'!\x00' # Double HEIGHT only (GS ! 0x01). DOUBLE_ON is 0x11 — double width AND height — # which halves the line to 16 columns and destroys the right-aligned price # column. Tall-only keeps all 32 columns, so an item line reads twice as big # across the kitchen while its price still lands flush right. TALL_ON=GS+b'!\x01'; TALL_OFF=GS+b'!\x00' INVERSE_ON=GS+b'B\x01'; INVERSE_OFF=GS+b'B\x00' # Second print color (ESC r n). The BTP-M300A honors it on two-color thermal # paper (black + red OR black + blue — the accent color is a property of the # paper roll, not selectable). Ignored harmlessly on plain paper. COLOR2_ON=ESC+b'r\x01'; COLOR2_OFF=ESC+b'r\x00' LF=b'\n' # Kitchen darkness boost (owner report 2026-08-02: BTP printed EVERYTHING red # — on two-color paper, black only develops at high head energy; red develops # at low energy, so an under-driven head turns the whole ticket red). When # KITCHEN_DARKNESS_BOOST=1, kitchen-path tickets get GS ( K speed=2 + # density=8 injected right after ESC @ (INIT resets these, so they must # follow it). Non-persistent per ESC/POS — a power-cycle or ESC @ clears # them — and printers that don't implement GS ( K ignore the bytes. DARKNESS_BOOST_ENABLED = os.getenv("KITCHEN_DARKNESS_BOOST", "").strip().lower() in ("1", "true", "yes", "on") DARKNESS_BOOST = GS+b'(K\x02\x00\x32\x02' + GS+b'(K\x02\x00\x31\x08' def _apply_darkness_boost(data): """Inject the boost after the leading ESC @ so INIT can't wipe it.""" if not DARKNESS_BOOST_ENABLED: return data if data.startswith(INIT): return INIT + DARKNESS_BOOST + data[len(INIT):] return DARKNESS_BOOST + data # ESC/POS real-time status transmission (DLE EOT n): the printer answers with one # status byte even while offline. n=2 = offline cause (cover open / paper end), # n=4 = roll-paper sensor (paper end/near-end). STATUS_OFFLINE = DLE + EOT + b'\x02' STATUS_PAPER = DLE + EOT + b'\x04' # GS ( H fn=48: request a "process ID response" — the printer echoes the 4-byte # ID (framed 0x37 ... 0x00) only after everything received before the command # has been processed. Ordinary buffered command, NOT real-time: that is what # makes it a completion barrier. Format: GS ( H pL pH fn m d1..d4. COMPLETION_REQ_PREFIX = GS + b'(H\x06\x00\x30\x30' COMPLETION_RSP_HEADER = b'\x37' _printed = set() _last_printer_error = "" _last_pending_orders_seen = 0 _last_print_failure = "" # True when the last print_ticket failure happened AFTER ticket bytes were # sent (silence/reset during the post-send sample) — i.e. the ticket may or # may not have printed. Distinguishes the bounded sent-unconfirmed path from # plain connect/send errors, which never delivered bytes and retry freely. _last_send_unconfirmed = False # True when the last successful print_ticket was confirmed by the GS ( H # completion barrier (not just status-sampled). _last_print_completed = False # Per-order count of sent-but-unconfirmed attempts, and orders held once the # count hits MAX_UNCONFIRMED_SENDS (dedup: no more automatic reprints). _unconfirmed_sends = {} _held_unconfirmed = set() _completion_seq = 0 # Printers seen answering an identifying DLE EOT byte this process lifetime. # Only for these does post-send silence mean "connection died"; a printer that # has never spoken (a mute Star model, an lpr relay) keeps legacy behavior so # the status sample cannot put it into an infinite reprint loop. _status_capable = set() # Set when the bridge rejects our print token (401/403). A rejected agent used # to be INVISIBLE: fetch_orders swallowed the 401, returned no orders, and the # next heartbeat reported a reachable printer — identical to a healthy store # with nothing to print. Tickets piled up in the cloud and nobody was paged. _auth_rejected = "" _spooler_lock_file = None def acquire_spooler_lock(): """Allow exactly one live polling process on this store computer. The LaunchAgent and a manually started copy previously could poll the same pending order at the same time. Both printed the full kitchen/customer/store packet before either one marked the order printed, producing six slips for one pickup. The OS releases this lock automatically if the process exits or crashes, so it cannot leave printing permanently wedged. """ global _spooler_lock_file if fcntl is None: log("[FATAL] Single-instance locking is unavailable on this computer") return False lock_path = SPOOLER_LOCK_PATH or "/tmp/zorros_print_agent.lock" lock_file = None try: lock_file = open(lock_path, "a+", encoding="ascii") fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) lock_file.seek(0) lock_file.truncate() lock_file.write(str(os.getpid())) lock_file.flush() _spooler_lock_file = lock_file return True except (OSError, IOError): try: if lock_file is not None: lock_file.close() except Exception: pass log( "[FATAL] Another Zorro's print agent is already running. " "This copy will exit to prevent duplicate tickets." ) return False def _log_ts(): return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def log(msg): """Timestamped stdout for the unattended polling loop. This agent runs headless behind a VPN that drops (7/25: two flapping episodes inside store hours). Undated lines made it impossible to tell a printer-unreachable burst from this minute apart from one from this morning — the log had to be dated by cross-referencing teleport-watchdog.log. Interactive commands keep plain print(). """ print(f"{_log_ts()} {msg}") def enc(t): return t.encode("ascii","replace") # HOT/COLD item marks (owner 2026-07-24). Self-contained keyword classifier — # the on-prem agent has no menu brain; COLD keywords win over HOT ones so a # grilled-chicken SALAD still reads cold. Default HOT (kitchen looks at it). COLD_WORDS=("take-and-bake","take and bake","ready to bake","iced","cold", "bottled","chilled","salad","juice","lemonade","water","matcha", "yogurt","fruit","cookie","muffin","cannoli") HOT_WORDS=("hot","grilled","toasted","baked","fries","soup","wings","pizza", "calzone","stromboli","burger","cheesesteak","parm","nashville", "quesadilla","meatball","nuggets","tenders","cutlet","breakfast", "egg","bacon","sausage") def item_temperature(item): name=str(item.get("name") or "").lower() for w in COLD_WORDS: if w in name: return "COLD" for w in HOT_WORDS: if w in name: return "HOT" cat=str(item.get("category") or item.get("category_id") or "").lower() if cat in ("drinks","salad","cold-subs","cookies","muffins", "sweet-croissants","bread","dough-ready-to-bake","fresh-fruit-cups"): return "COLD" return "HOT" def temperature_tag(temp): if temp=="COLD": return INVERSE_ON+enc(" COLD ")+INVERSE_OFF+enc(" ") return COLOR2_ON+enc("*HOT*")+COLOR2_OFF+enc(" ") # ── Ticket width (standard 32-column ESC/POS at normal size) ──────────────── TICKET_WIDTH = 32 # ── Half-and-half modifier grouping (mirrors backend/printer_client.py) ───── # The server flattens pizza builds into modifier strings like # "left half: pepperoni" / "1st half no garlic". Those strings are a contract # (the KDS board stores them verbatim), so grouping into a LEFT:/RIGHT: block # happens here at print time only. HALF_LINE_PREFIXES = ( # (prefix, side label, render topping as a removal) ("left half: ", "LEFT", False), ("right half: ", "RIGHT", False), ("1st half no ", "1ST HALF", True), ("2nd half no ", "2ND HALF", True), ("1st half: ", "1ST HALF", False), ("2nd half: ", "2ND HALF", False), ("half: ", "HALF", False), ) def parse_half_modifier(mod_str): """Return (side_label, TOPPING_TEXT) for a half-scoped modifier, else None.""" mod_lower = mod_str.lower() for prefix, label, is_removal in HALF_LINE_PREFIXES: if mod_lower.startswith(prefix): topping = mod_str[len(prefix):].strip().upper() if not topping: return None return label, ("NO " + topping) if is_removal else topping return None def group_half_modifiers(mods): """Split modifier strings into ({side: [toppings…]}, [non-half mods]).""" half_groups, other_mods = {}, [] for mod in mods: if isinstance(mod, dict): mod = mod.get("name", str(mod)) mod_str = str(mod).strip() if not mod_str: continue parsed = parse_half_modifier(mod_str) if parsed: half_groups.setdefault(parsed[0], []).append(parsed[1]) else: other_mods.append(mod_str) return half_groups, other_mods def wrap_lines(text, avail): """Word-wrap text to at most `avail` chars per line, hard-breaking any single word longer than `avail` (wrap, never truncate).""" avail = max(1, int(avail)) lines, cur = [], '' for word in text.split(): while len(word) > avail: if cur: lines.append(cur); cur = '' lines.append(word[:avail]); word = word[avail:] if not word: continue if len(cur) + len(word) + (1 if cur else 0) <= avail: cur = (cur + ' ' + word).strip() else: lines.append(cur); cur = word if cur: lines.append(cur) return lines or [''] def mod_block(prefix, text, width=TICKET_WIDTH): """Render one modifier line wrapped to the ticket width; continuation lines indent to align under the text.""" indent = len(prefix) parts = wrap_lines(text, width - indent) out = enc(prefix + parts[0]) + LF for cont in parts[1:]: out += enc(' ' * indent + cont) + LF return out def money(v): try: return f"${float(v):.2f}" except Exception: return "" def is_positive(v): try: return float(v) > 0 except Exception: return False def auth_headers(): if not PRINT_BRIDGE_TOKEN: raise RuntimeError("PRINT_BRIDGE_TOKEN is required for the print bridge") return {"X-Print-Bridge-Token": PRINT_BRIDGE_TOKEN} def post_heartbeat(printer_reachable=False, pending_orders_seen=None, mode="polling"): """Tell Railway the in-store print spooler is alive without sending PII.""" payload = { "printer_ip": PRINTER_IP, "printer_port": PRINTER_PORT, "printer_reachable": bool(printer_reachable), "mode": mode, "version": SPOOLER_VERSION, "last_error": _last_printer_error or None, "pending_orders_seen": _last_pending_orders_seen if pending_orders_seen is None else pending_orders_seen, } try: r = requests.post( f"{RAILWAY_URL}/api/print/heartbeat", json=payload, headers=auth_headers(), timeout=5, ) if r.status_code != 200: log(f"[HEARTBEAT WARN] print bridge returned HTTP {r.status_code}") return False return True except Exception as e: log(f"[HEARTBEAT WARN] {e}") return False def _item_line_total(item): """Item line price for the ticket: line_total (qty-aware, includes paid modifiers), falling back to unit_total*qty then price*qty. 0 = unpriced.""" for key, per_unit in (("line_total", False), ("unit_total", True), ("price", True)): try: v = float(item.get(key)) except (TypeError, ValueError): continue if v > 0: if per_unit: try: v *= float(item.get("quantity") or item.get("qty") or 1) except (TypeError, ValueError): pass return v return 0.0 def _derive_ticket_money(o): """Classify total_price and return (food, fee_display, grand, shape). total_price is the GRAND total (food+tax+fee) for web/POS orders but food-only for phone orders; assuming food-only for everyone printed COLLECT $159.68 on the $150.34 web order #78 (2026-07-26). Local copy of backend/ticket_money.py's derive_ticket_money — keep the two in lockstep (backend/test_kitchen_ticket_money_shapes.py asserts parity). Only used when the server payload has no explicit food_subtotal/grand_total. Ambiguous payloads (unpriced items) keep the historical food-only math: a too-low COLLECT loses money silently. grand INCLUDES the tip. """ def _f(key): try: return float(o.get(key) or 0) except Exception: return 0.0 total, tax, fee, tip = _f("total_price"), _f("tax"), _f("delivery_fee"), _f("tip") items = o.get("items") or o.get("order_items") or [] items_sum = round(sum(_item_line_total(i) for i in items if isinstance(i, dict)), 2) is_delivery = "deliv" in str(o.get("order_type") or "").lower() shape, food, fee_display = "food_only", total, fee grand = round(total + tax + fee + tip, 2) if items_sum > 0.005 and total > items_sum + 0.02: if abs(total - (items_sum + tax + fee + tip)) <= 0.02: shape, food, grand = "grand_tip_included", round(total - tax - fee - tip, 2), total elif abs(total - (items_sum + tax + fee)) <= 0.02: shape, food = "grand_tip_separate", round(total - tax - fee, 2) grand = round(total + tip, 2) elif fee <= 0.005 and is_delivery: # Fee folded into the total while the delivery_fee column is 0 # (the order #78 family) — recover it so lines sum to COLLECT. hidden_tip_in = round(total - items_sum - tax - tip, 2) hidden_tip_out = round(total - items_sum - tax, 2) if tip > 0.005 and 0.02 < hidden_tip_in <= 25.0: shape = "grand_hidden_fee_tip_included" food, fee_display, grand = items_sum, hidden_tip_in, total elif 0.02 < hidden_tip_out <= 25.0: shape, food, fee_display = "grand_hidden_fee", items_sum, hidden_tip_out grand = round(total + tip, 2) if food <= 0: shape, food, fee_display = "food_only", total, fee grand = round(total + tax + fee + tip, 2) return round(food, 2), round(fee_display, 2), round(grand, 2), shape def build_ticket(o, include_specs=True): """Wawa-style kitchen ticket: big black item names, every modifier on its own marked line, accent colour only on lines the kitchen acts on (owner ruling 2026-07-28), ***FULFILMENT*** banner, instructions impossible to miss. Mirrors the layout language of backend/printer_client.py's Wawa ticket, scaled to 32 columns. include_specs=False drops the prep SPEC reference lines (owner 2026-08-02: 'MEASUREMENT PENDING' guidance on a customer's receipt looks bad) — used for the duplicate CUSTOMER/STORE/DRIVER copies; the kitchen ticket always keeps them.""" b = bytearray() b += INIT + ALIGN_CTR + BOLD_ON + DOUBLE_ON b += enc(" ZORRO'S ") + LF + DOUBLE_OFF + BOLD_OFF ticket = o.get("ticket_number") or o.get("id","??") src = (o.get("source","ONLINE")).upper() otype = (o.get("order_type") or "PICKUP").upper() # Ticket number BIG (owner 2026-07-26, order #62): the small one-line # "ORDER #D-12" was missed at the counter. b += BOLD_ON + DOUBLE_ON + enc(f"TICKET #{ticket}") + LF + DOUBLE_OFF + BOLD_OFF b += BOLD_ON + enc(f"[{src}]") + LF + BOLD_OFF b += enc(datetime.datetime.now().strftime("%m/%d %I:%M %p")) + LF b += enc("="*32) + LF + ALIGN_LEFT items = list(o.get("items") or o.get("order_items") or []) for idx, item in enumerate(items): name = item.get("name") or item.get("display_name","Item") qty = item.get("quantity") or item.get("qty",1) # HOT/COLD mark leads every item line (owner 2026-07-24): HOT in the # accent color on two-color paper, COLD inverse. temp = item_temperature(item) b += TALL_ON + BOLD_ON + temperature_tag(temp) # Per-item price on the right (owner 2026-07-26, order #62): the one # paper ticket must price every line like any other receipt. The # HOT/COLD tag occupies printable columns, so budget for it. # The WHAT-TO-MAKE line is the one the kitchen reads from a distance, # so it prints double-height and BLACK — the same hierarchy the POS # ticket uses (big black items, colour reserved for exceptions). # Everything around it stays normal size; if every line is the same # weight, none of them are. name_txt = f"{qty}x {name.upper()}" price_txt = money(_item_line_total(item)) if _item_line_total(item) > 0 else "" tag_cols = 7 if temp == "COLD" else 6 b += TALL_ON if price_txt and tag_cols + len(name_txt) + 1 + len(price_txt) <= TICKET_WIDTH: pad = TICKET_WIDTH - tag_cols - len(name_txt) - len(price_txt) b += enc(name_txt + " " * pad + price_txt) + LF + TALL_OFF + BOLD_OFF else: b += enc(name_txt) + LF if price_txt: b += enc(price_txt.rjust(TICKET_WIDTH)) + LF b += TALL_OFF + BOLD_OFF # Half-and-half sides print as one grouped, bold block (LEFT:/RIGHT:) # so each side reads as a complete build. Removals print inverse, # add-ons in the accent color, "side of" lines get a pack checkbox # (owner 2026-07-24: highlighted for visibility). mods = item.get("modifiers") or [] half_groups, other_mods = group_half_modifiers(mods) for side_label, toppings in half_groups.items(): b += TALL_ON + BOLD_ON + mod_block(" ", side_label + ": " + " + ".join(toppings)) + BOLD_OFF + DOUBLE_OFF for mod in other_mods: m = str(mod); ml = m.lower() if (ml.startswith(("spec:", "build:")) or ml == "build not verified - staff confirm"): # Defensive compatibility with orders saved by older servers: # internal recipe/spec diagnostics never belong on a live # human kitchen ticket. continue # Owner ruling 2026-07-28: the accent colour marks ONLY the lines # the kitchen has to act on differently — removals, sides # (dressing/sauce) and extras. Everything else stays black so the # marked lines are the ones that catch the eye. if ml.startswith("no ") or ml.startswith("without "): b += (BOLD_ON + COLOR2_ON + INVERSE_ON + mod_block(" ! ", m.upper()) + INVERSE_OFF + COLOR2_OFF + BOLD_OFF) elif ml.startswith("side of ") or ml.startswith("side "): b += (BOLD_ON + COLOR2_ON + mod_block(" [ ] ", m.upper()) + COLOR2_OFF + BOLD_OFF) elif ml.startswith("extra ") or ml.startswith("add "): b += BOLD_ON + COLOR2_ON + mod_block(" + ", m.upper()) + COLOR2_OFF + BOLD_OFF elif ml.startswith("light ") or ml.startswith("easy "): b += BOLD_ON + mod_block(" ~ ", m.upper()) + BOLD_OFF elif ml.startswith("sub ") or ml.startswith("swap "): b += BOLD_ON + mod_block(" > ", m.upper()) + BOLD_OFF else: b += mod_block(" + ", m.upper()) # Per-item special instructions (owner order #78: "SUB SPINACH for # lettuce" was stored on the item but the kitchen never saw it — # only order-level notes printed). Same key precedence as the POS # path (pos_submission._pos_item_for). Skip if a modifier already # says the same thing. special = str(item.get("special_instructions") or item.get("notes") or "").strip() if special and special.lower() not in {str(m).strip().lower() for m in (other_mods or [])}: b += BOLD_ON + COLOR2_ON + mod_block(" >> ", special.upper()) b += COLOR2_OFF + BOLD_OFF if idx < len(items) - 1: b += enc("- " * (TICKET_WIDTH // 2)) + LF b += enc("="*32) + LF # Fulfilment banner, styled like the POS ticket's ***TO GO*** — accent # colour, double height, starred so it is unmissable at a glance. Whether # this goes out the door or waits at the counter is the single fact staff # most need off the paper. b += (ALIGN_CTR + BOLD_ON + COLOR2_ON + TALL_ON + enc(f"***{otype}***") + LF + TALL_OFF + COLOR2_OFF + BOLD_OFF + ALIGN_LEFT) # The name is how the counter matches ticket to customer at handover, so # it carries the accent colour too (owner ruling 2026-07-28). if o.get("customer_name"): b += (BOLD_ON + COLOR2_ON + enc(f"Name: {o['customer_name']}") + COLOR2_OFF + BOLD_OFF + LF) if o.get("customer_phone"): b += enc(f"Phone: {o['customer_phone']}") + LF if o.get("address") and "DELIVERY" in otype: # Delivery address tall + bold, wrapped with continuation indent so a # long street name never hard-wraps mid-word at the printer. b += TALL_ON + BOLD_ON + mod_block("ADDR: ", str(o["address"])) + BOLD_OFF + DOUBLE_OFF if o.get("payment_method"): pay = str(o.get("payment_method") or "").upper() status = str(o.get("payment_status") or "").upper() b += enc(f"Pay: {pay}{(' / ' + status) if status else ''}") + LF def _f(key): try: return float(o.get(key) or 0) except Exception: return 0.0 tax_v, tip_v = _f("tax"), _f("tip") # Prefer the server's explicit money fields (grand_total INCLUDES tip); # older servers only send raw columns, where total_price means "grand" # for web/POS orders but "food" for phone orders — derive locally then. # Naively summing the columns double-counted tax+fee on web orders # (order #78 printed COLLECT $159.68 for a $150.34 order). food, collect = _f("food_subtotal"), _f("grand_total") if collect > 0: fee_v = _f("delivery_fee_display") or _f("delivery_fee") else: food, fee_v, collect, _shape = _derive_ticket_money(o) if food > 0: b += enc(f"Food: ${food:.2f}") + LF if tax_v > 0: b += enc(f"Tax: ${tax_v:.2f}") + LF if fee_v > 0: b += enc(f"Deliv: ${fee_v:.2f}") + LF if tip_v > 0: b += BOLD_ON + enc(f"TIP: ${tip_v:.2f}") + LF + BOLD_OFF # PAID / NOT PAID banner (owner 2026-07-26, order #62): the single paper # ticket must say unmistakably whether money is still owed. An order with # no payment_status at all (older payloads) keeps the legacy COLLECT line. status_l = str(o.get("payment_status") or "").strip().lower() if status_l == "paid": b += ALIGN_CTR + BOLD_ON + DOUBLE_ON + enc(" ** PAID ** ") + LF b += DOUBLE_OFF + BOLD_OFF + ALIGN_LEFT elif status_l and collect > 0: b += ALIGN_CTR + INVERSE_ON + BOLD_ON + DOUBLE_ON b += enc(" NOT PAID ") + LF b += enc(f"COLLECT ${collect:.2f}") + LF b += DOUBLE_OFF + BOLD_OFF + INVERSE_OFF + ALIGN_LEFT elif collect > 0: b += BOLD_ON + DOUBLE_ON + enc(f"COLLECT ${collect:.2f}") + LF + DOUBLE_OFF + BOLD_OFF # Order-level notes get a full SPECIAL INSTRUCTIONS section (Wawa-style) # instead of one small NOTE line that hard-wrapped past 32 columns. if o.get("notes"): b += enc("-"*32) + LF b += ALIGN_CTR + INVERSE_ON + BOLD_ON + enc(" SPECIAL INSTRUCTIONS ") + LF b += BOLD_OFF + INVERSE_OFF + ALIGN_LEFT b += TALL_ON + BOLD_ON for line in wrap_lines(str(o["notes"]).upper(), TICKET_WIDTH - 1): b += enc(" " + line) + LF b += BOLD_OFF + DOUBLE_OFF b += enc(" "*32) + LF + CUT return bytes(b) def build_test_order(): return { "id": "TEST", "ticket_number": "TEST", "source": "SETUP", "order_type": "pickup", "customer_name": "Printer Test", "customer_phone": "", "notes": "If you can read this, the kitchen printer is connected.", "items": [ { "name": "TEST PRINT - ZORRO'S KITCHEN", "quantity": 1, "modifiers": [ "Local printer path works", "Phone orders will print here", "Keep this computer running", ], } ], } def _send_raw(ip, port, data, timeout=5): with socket.create_connection((ip, port), timeout=timeout) as s: s.sendall(data) def _note_status_capable(ip, port, *replies): if any(_status_identifies(b) for b in replies): _status_capable.add((ip, port)) def _post_send_status_sample(sock, ip, port): """Sample printer status on the SAME socket, after the ticket bytes. This is a STATUS SAMPLE, not a completion barrier: DLE EOT is a real-time command the printer answers on receipt, while the ticket bytes may still sit unprocessed in its buffers. So a reply proves only that the connection survived past the send and reports the printer's current condition; it does NOT prove the ticket was parsed or physically printed. What silence from a known DLE-EOT-capable printer DOES tell us is that the link likely died mid-stream — the order-#59 hole — so that case fails the print into the bounded sent-unconfirmed retry instead of being marked printed. Returns None to proceed with mark-printed (sampled OK, or unprovable on a printer that never answers status), else a short failure reason. """ global _last_send_unconfirmed offline = _query_status_byte(sock, STATUS_OFFLINE, timeout=SEND_STATUS_TIMEOUT) if offline is None: if (ip, port) in _status_capable: _last_send_unconfirmed = True return "no status reply after send — link lost, delivery unconfirmed" # Never seen this printer speak DLE EOT: silence is ambiguous (mute # printer vs dead link). Keep legacy send-and-mark rather than reprint # forever, but say so once per ticket. log(f"[SEND CHECK WARN] {ip}:{port} gave no post-send status reply; delivery unverified") return None paper = _query_status_byte(sock, STATUS_PAPER) _note_status_capable(ip, port, offline, paper) fault = _interpret_status(offline, paper) if fault: return f"printer reported '{fault}' right after send — ticket may not have printed" return None def _next_completion_id(): global _completion_seq _completion_seq = (_completion_seq + 1) % 10000 return "{:04d}".format(_completion_seq).encode("ascii") def _confirm_completion(sock, ident, timeout=None): """Wait for the GS ( H process-ID echo: a TRUE job-completion signal. The request must already have been sent after the ticket bytes. Because GS ( H is an ordinary buffered command, the printer replies only after all preceding data has been processed — so seeing our ID back means the ticket was consumed. Scans a small window for 0x37 0x00 to tolerate an interleaved ASB/status byte. Returns True on confirmation. """ if timeout is None: timeout = COMPLETION_TIMEOUT expected = COMPLETION_RSP_HEADER + ident buf = b"" try: sock.settimeout(timeout) while len(buf) < 64: chunk = sock.recv(16) if not chunk: return False buf += chunk if expected in buf: return True except (OSError, socket.timeout): return False return False def _send_ticket(ip, port, data, timeout=5): """Send a ticket on one connection, then check on it as well as we can. With PRINT_COMPLETION_CHECK=1 (requires --probe-completion certification): appends a GS ( H process-ID request and waits for the echo — genuine confirmation the ticket was processed. Otherwise falls back to the DLE EOT status sample (link survival + status, not proof of printing). Returns None on success, else a failure-reason string. Raises on connect/send errors like _send_raw did. """ global _last_send_unconfirmed with socket.create_connection((ip, port), timeout=timeout) as s: if COMPLETION_CHECK_ENABLED: global _last_print_completed ident = _next_completion_id() s.sendall(data + COMPLETION_REQ_PREFIX + ident) if _confirm_completion(s, ident): _last_print_completed = True return None _last_send_unconfirmed = True return "no completion response — printing unconfirmed" s.sendall(data) if not SEND_STATUS_CHECK_ENABLED: return None return _post_send_status_sample(s, ip, port) def print_ticket(data): global _last_print_failure, _last_send_unconfirmed, _last_print_completed _last_print_failure = "" _last_send_unconfirmed = False _last_print_completed = False # Kitchen path only — the receipt-station Epson prints black already. data = _apply_darkness_boost(data) try: why = _send_ticket(PRINTER_IP, PRINTER_PORT, data) if why is None: return True log(f"[PRINTER ERROR] {why}") except Exception as e: why = str(e) log(f"[PRINTER ERROR] {e}") _last_print_failure = why # Primary unreachable — fall back to the backup printer if one is configured. # Only ONE of the two ever prints a given ticket in a poll: we return True as # soon as either succeeds, so mark-printed acks it and it won't reprint. if BACKUP_PRINTER_IP: try: why = _send_ticket(BACKUP_PRINTER_IP, BACKUP_PRINTER_PORT, data) if why is None: _last_print_failure = "" log(f"[BACKUP OK] Printed on backup {BACKUP_PRINTER_IP}:{BACKUP_PRINTER_PORT} (primary down)") return True log(f"[BACKUP PRINTER ERROR] {why}") except Exception as e: why = str(e) log(f"[BACKUP PRINTER ERROR] {e}") _last_print_failure = f"{_last_print_failure}; backup: {why}" return False def print_receipt_copy(data, label=""): """Print a duplicate receipt on the receipt-station printer when configured. Falls back to the normal kitchen path (print_ticket, incl. its backup chain) if no receipt printer is set or it fails — copies are best-effort everywhere, but the fallback keeps them flowing when the Epson is down. Every slip logs its destination so a pile of unexpected paper at either station can be traced to the order and path that produced it. """ if RECEIPT_PRINTER_IP: # OWNER REPORT 2026-08-03: every order came out ~4x on the receipt # printer AND ~4x in the kitchen. The bytes had already reached the # Epson and PRINTED — only the post-send status check came back # unanswered (the Epson does not answer like the BTP does) — and this # fallback then reprinted the very same slip in the kitchen. # # So: once the socket send SUCCEEDS the paper is already out, and the # copy must NEVER be re-sent to another printer. Only a genuine # delivery failure (connect/send raised — nothing left the Mac) may # fall back. Duplicate paper at two stations is far worse than one # unverified customer copy, which staff can reprint on demand. try: why = _send_ticket(RECEIPT_PRINTER_IP, RECEIPT_PRINTER_PORT, data) if why is None: log(f"[COPY] {label or 'copy'} -> receipt {RECEIPT_PRINTER_IP}") else: log(f"[COPY] {label or 'copy'} -> receipt {RECEIPT_PRINTER_IP} " f"(delivered, unverified: {why}) — NOT reprinting elsewhere") return True except Exception as e: log(f"[RECEIPT PRINTER ERROR] {e} — nothing delivered; " f"falling back to kitchen printer") ok = print_ticket(data) if ok: log(f"[COPY] {label or 'copy'} -> kitchen {PRINTER_IP}") return ok def _status_identifies(byte_val): """True if byte_val looks like an ESC/POS real-time status byte. The ESC/POS spec fixes bit0=0, bit1=1, bit4=1, bit7=0 in every DLE EOT reply — the store BTP-M300A answers exactly that (certified 7/25: 0x16, 0x12, 0x12, 0x12). This function previously required bit4=0, which no spec-conforming reply has — so the REAL printer's bytes never identified and a genuine paper-out (0x32) fell through as "unknown printer". Both patterns are accepted now: the spec one, plus the legacy bit4=0 shape for any emulation that sends it. A printer that does not speak DLE EOT (some Star models, a CUPS/lpr relay, a USB bridge) returns nothing or noise; those fail this check and are treated as "status unknown" so we never wedge the line on an un-queryable printer. """ if byte_val is None: return False return (byte_val & 0b10010011) == 0b00010010 or (byte_val & 0b10010011) == 0b00000010 def _query_status_byte(sock, command, timeout=1.5): """Send one DLE EOT command and read its single-byte reply (None on silence).""" try: sock.sendall(command) sock.settimeout(timeout) chunk = sock.recv(1) except (OSError, socket.timeout): return None return chunk[0] if chunk else None def _interpret_status(offline, paper): """Map DLE EOT status bytes to a human fault reason, or None if healthy/unknown. Returns None when neither byte identifies as ESC/POS status (unknown printer) so the caller falls back to its prior send-and-mark behavior. """ if not _status_identifies(offline) and not _status_identifies(paper): return None reasons = [] if _status_identifies(offline): if offline & 0x04: # bit2: cover open reasons.append("cover open") if offline & 0x20: # bit5: printing stopped, paper end reasons.append("out of paper") if offline & 0x40: # bit6: error occurred reasons.append("printer error") if _status_identifies(paper) and (paper & 0x60) == 0x60: # bits5,6: roll paper end if "out of paper" not in reasons: reasons.append("out of paper") return ", ".join(reasons) if reasons else None def check_printer_fault(ip=None, port=None, timeout=3): """Ask the printer whether it can actually print RIGHT NOW. Returns a short human reason string ("out of paper", "cover open", ...) when the printer reports a definite fault, else None. Pure: no global mutation. A None result means "healthy OR can't tell" — including a printer that is unreachable (that failure is already handled by print_ticket's retry) or one that does not answer DLE EOT. The caller only blocks marking-printed on a DEFINITE fault, so an un-queryable printer keeps its prior behavior. """ if not STATUS_CHECK_ENABLED: return None ip = ip or PRINTER_IP port = port or PRINTER_PORT try: with socket.create_connection((ip, port), timeout=timeout) as s: offline = _query_status_byte(s, STATUS_OFFLINE) paper = _query_status_byte(s, STATUS_PAPER) except Exception: return None # Remember that this printer answers DLE EOT: from then on, post-send # silence in _confirm_drained means a dead link, not a mute printer. _note_status_capable(ip, port, offline, paper) return _interpret_status(offline, paper) def _note_bridge_status(status_code, what): """Remember a print-token rejection so the agent stops pretending it is well. 401/403 means this spooler is locked out of the cloud: it will never see a ticket and every report it sends is discarded. That is an outage, not an idle store, so it must be loud locally and must not be reported as a healthy printer. Returns True when the call was rejected. """ global _auth_rejected if status_code not in (401, 403): return False reason = (f"print bridge rejected our PRINT_BRIDGE_TOKEN (HTTP {status_code} " f"on {what}) — this spooler cannot see or acknowledge tickets") if _auth_rejected != reason: log(f"[AUTH ERROR] {reason}. Fix PRINT_BRIDGE_TOKEN on this computer.") _auth_rejected = reason return True def _flag_printer_fault(reason): """Record a printer fault and page staff immediately. Reports the printer as NOT reachable in an out-of-band heartbeat so the Railway spooler watchdog (ops_alerting.check_print_spooler) pages the owner with the printer power/Wi-Fi/paper runbook — instead of waiting for the next polling heartbeat, which bare TCP reachability would report as healthy. """ global _last_printer_error _last_printer_error = reason post_heartbeat(printer_reachable=False, mode="fault") def print_test_ticket(): ok = print_ticket(build_ticket(build_test_order())) if ok: print("[OK] Test kitchen ticket sent. Printer spooler path is ready.") else: print("[WARN] Test kitchen ticket failed. Check printer IP, port, WiFi, and power.") return ok def probe_completion(ip=None, port=None): """Certify GS ( H fn=48 process-ID support on the printer (owner-approved). PRINTS ONE SMALL SLIP. If the printer does not implement GS ( H it may render the trailing request bytes as stray characters on that slip — which is exactly the evidence needed, and why this must never run unattended or against a printer mid-service. Only after this probe passes is it safe to set PRINT_COMPLETION_CHECK=1. Interactive command: plain print(). """ ip = ip or PRINTER_IP port = port or PRINTER_PORT ident = _next_completion_id() slip = (INIT + ALIGN_CTR + BOLD_ON + enc("COMPLETION PROBE (GS ( H)") + LF + BOLD_OFF + enc(_log_ts()) + LF + enc("ID " + ident.decode("ascii")) + LF + CUT) print(f"Probing {ip}:{port} for GS ( H process-ID completion support...") try: with socket.create_connection((ip, port), timeout=5) as s: t0 = time.time() s.sendall(slip + COMPLETION_REQ_PREFIX + ident) ok = _confirm_completion(s, ident, timeout=10) elapsed = time.time() - t0 except Exception as e: print(f"[FAIL] Probe could not run: {e}") return False if ok: print(f"[OK] Process-ID echo received {elapsed:.2f}s after send — and GS ( H replies only") print(" after all preceding data is processed, so this printer supports the true") print(" completion barrier. PRINT_COMPLETION_CHECK=1 is safe to enable.") return True print(f"[NO] No process-ID echo within 10s (waited {elapsed:.2f}s).") print(" Inspect the probe slip: stray characters after the ID line mean the printer") print(" does NOT implement GS ( H. Leave PRINT_COMPLETION_CHECK unset/0.") return False def fetch_bridge_test_order(): try: r = requests.get( f"{RAILWAY_URL}/api/print/test", headers=auth_headers(), timeout=8, ) if r.status_code == 200: return r.json().get("order") or build_test_order() print(f"[TEST FETCH ERROR] print bridge returned HTTP {r.status_code}") except Exception as e: print(f"[TEST FETCH ERROR] {e}") return None def check_railway_health(): try: r = requests.get(f"{RAILWAY_URL}/api/print/health", timeout=5) if r.status_code == 200: return True, "Railway print bridge reachable." return False, f"Railway print bridge returned HTTP {r.status_code}." except Exception as e: return False, f"Railway print bridge unreachable: {e}" def check_pending_print_auth(): try: r = requests.get( f"{RAILWAY_URL}/api/orders/pending-print", headers=auth_headers(), timeout=8, ) if r.status_code == 200: orders = r.json().get("orders", []) return True, f"Pending-print API accepted token; {len(orders)} order(s) waiting." if r.status_code in {401, 403}: return False, "Pending-print API rejected PRINT_BRIDGE_TOKEN." return False, f"Pending-print API returned HTTP {r.status_code}." except Exception as e: return False, f"Pending-print API check failed: {e}" def check_phone_order_readiness(): """Read the public, non-PII phone-order readiness report from Railway.""" try: r = requests.get(f"{RAILWAY_URL}/api/phone-order/readiness", timeout=8) if r.status_code == 200: return True, r.json() return False, {"error": f"Phone-order readiness returned HTTP {r.status_code}."} except Exception as e: return False, {"error": f"Phone-order readiness check failed: {e}"} def _yes_no(value): return "yes" if value else "no" def print_public_status(): """Print a no-token, no-paper status summary for the store computer.""" print("Zorro's Print Agent Status") print(f"Railway: {RAILWAY_URL}") print(f"Printer target: {PRINTER_IP}:{PRINTER_PORT}") print("") ok, report = check_phone_order_readiness() if not ok: print(f"[FAIL] Railway readiness: {report.get('error', 'unavailable')}") print("Next: check internet connection, then run --status again.") return False checks = report.get("checks") or {} print(f"Status: {report.get('status', 'unknown')}") print(f"Readiness: {report.get('readiness_pct', '?')}%") print(f"Menu items: {report.get('menu_item_count', '?')}") print("") for key, label in ( ("menu_brain_loaded", "Menu brain loaded"), ("live_order_enabled", "Live orders enabled"), ("phone_auto_activation_enabled", "Phone auto POS/KDS activation"), ("orders_schema_ready", "Order schema ready"), ("print_bridge_token_configured", "Railway print token configured"), ("store_spooler_auto_print_ready", "Store spooler + printer ready"), ): print(f"[{_yes_no(checks.get(key)).upper()}] {label}") blockers = report.get("blockers") or [] if blockers: print("") print("Blockers:") for blocker in blockers: print(f" - {blocker}") print("") if report.get("ready_for_phone_orders"): print("[READY] Phone orders can enter POS/KDS and print automatically.") elif report.get("software_ready_without_physical_printer"): print("[WAITING] Software is ready. Start this spooler on the store Wi-Fi.") else: print("[NOT READY] Fix the failed software checks before taking live phone orders.") print(f"Next: {report.get('next_action', 'Run --doctor on the store computer.')}") return True def print_doctor(): """Run no-paper setup diagnostics for the store computer.""" print("Zorro's Print Agent Doctor") print(f"Railway: {RAILWAY_URL}") print(f"Printer: {PRINTER_IP}:{PRINTER_PORT}") print("") checks = [] token_ready = bool(PRINT_BRIDGE_TOKEN) checks.append(("PRINT_BRIDGE_TOKEN", token_ready, "Set" if token_ready else "Missing")) if not token_ready: for label, ok, detail in checks: print(f"[{'OK' if ok else 'FAIL'}] {label}: {detail}") print("") print("Next: set PRINT_BRIDGE_TOKEN, then run --doctor again.") return False railway_ok, railway_detail = check_railway_health() checks.append(("Railway bridge", railway_ok, railway_detail)) pending_ok, pending_detail = check_pending_print_auth() checks.append(("Pending-print auth", pending_ok, pending_detail)) printer_ok = ping_printer(timeout=3) printer_detail = "Printer TCP port reachable." if printer_ok else (_last_printer_error or "Printer not reachable.") checks.append(("Printer network", printer_ok, printer_detail)) # Reachable over TCP isn't enough — a powered printer can be out of paper or # cover-open. Ask its real status so setup catches a no-paper printer here. paper_fault = check_printer_fault() if printer_ok else None if printer_ok: checks.append(("Printer status", paper_fault is None, f"Printer reports: {paper_fault}." if paper_fault else "Printer reports ready (or does not support status).")) printer_ok = printer_ok and not paper_fault heartbeat_ok = post_heartbeat(printer_reachable=printer_ok, mode="doctor") checks.append(("Heartbeat", heartbeat_ok, "Railway received heartbeat." if heartbeat_ok else "Heartbeat failed.")) for label, ok, detail in checks: print(f"[{'OK' if ok else 'FAIL'}] {label}: {detail}") ready = all(ok for _, ok, _ in checks) print("") if ready: print("[READY] Spooler prerequisites are ready. Start the live spooler or run --test-bridge-print.") else: print("[NOT READY] Fix the FAIL item(s), then run --doctor again.") return ready def print_bridge_test_ticket(): order = fetch_bridge_test_order() if not order: print("[WARN] Bridge test ticket failed. Check RAILWAY_URL and PRINT_BRIDGE_TOKEN.") return False ok = print_ticket(build_ticket(order)) if ok: print("[OK] Bridge test kitchen ticket sent. Railway, token, and printer path are ready.") else: print("[WARN] Bridge test print failed. Check printer IP, port, WiFi, and power.") return ok def ping_printer(timeout=3): global _last_printer_error try: with socket.create_connection((PRINTER_IP, PRINTER_PORT), timeout=timeout) as s: s.close() _last_printer_error = "" return True except Exception as e: _last_printer_error = str(e) log(f"[PRINTER CHECK] Not reachable at {PRINTER_IP}:{PRINTER_PORT} — {e}") return False def fetch_orders(): global _last_pending_orders_seen try: r = requests.get( f"{RAILWAY_URL}/api/orders/pending-print", headers=auth_headers(), timeout=8, ) if r.status_code == 200: orders = r.json().get("orders",[]) _last_pending_orders_seen = len(orders) return orders if not _note_bridge_status(r.status_code, "pending-print"): log(f"[FETCH ERROR] print bridge returned HTTP {r.status_code}") except Exception as e: log(f"[FETCH ERROR] {e}") _last_pending_orders_seen = 0 return [] def mark_printed(order_id): return requests.post( f"{RAILWAY_URL}/api/orders/{order_id}/mark-printed", headers=auth_headers(), timeout=5, ) def mark_printed_or_warn(order_id): try: response = mark_printed(order_id) if response.status_code == 200: return True if not _note_bridge_status(response.status_code, "mark-printed"): log(f"[MARK ERROR] order #{order_id} returned HTTP {response.status_code}") except Exception as e: log(f"[MARK ERROR] order #{order_id}: {e}") return False def fetch_print_jobs(): """Generic print jobs (Digital Fridge labels): opaque ESC/POS payloads the cloud composed — push verbatim, never rendered here.""" try: r = requests.get( f"{RAILWAY_URL}/api/print/pending-jobs", headers=auth_headers(), timeout=8, ) if r.status_code == 200: return r.json().get("jobs", []) if not _note_bridge_status(r.status_code, "pending-jobs"): print(f"[JOB FETCH ERROR] print bridge returned HTTP {r.status_code}") except Exception as e: print(f"[JOB FETCH ERROR] {e}") return [] def process_pending_jobs_once(): import base64 printed_count = 0 for job in fetch_print_jobs(): jid = job.get("id") if not jid or jid in _printed_jobs: continue fault = check_printer_fault() if fault: _flag_printer_fault(fault) print(f"[PRINTER FAULT] {fault} — holding job #{jid}; will retry") break try: payload = base64.b64decode(job.get("payload_b64") or "") except Exception as e: requests.post( f"{RAILWAY_URL}/api/print/jobs/{jid}/mark-failed", headers=auth_headers(), json={"error": f"bad payload: {e}"}, timeout=5) continue if payload and print_ticket(payload): try: requests.post( f"{RAILWAY_URL}/api/print/jobs/{jid}/mark-printed", headers=auth_headers(), timeout=5) _printed_jobs.add(jid) printed_count += 1 print(f"Printed job #{jid} — {job.get('title', '')}") except Exception as e: print(f"[JOB MARK ERROR] #{jid}: {e} — may reprint") else: print(f"Failed job #{jid} — will retry") return printed_count _printed_jobs = set() def maybe_kick_drawer(order): """Pop the cash drawer for a COMPLETED cash sale (best-effort). Only a cash-method ticket already marked paid kicks — an unpaid pay-at-counter order must not open the drawer before money changes hands, and card sales never do. Fires after the ticket printed and was acked, so a print failure never leaves the drawer open with no ticket. """ if not DRAWER_KICK_ENABLED: return False method = str(order.get("payment_method") or "").strip().lower() status = str(order.get("payment_status") or "").strip().lower() if method != "cash" or status != "paid": return False try: _send_raw(PRINTER_IP, PRINTER_PORT, OPEN_DRAWER) log(f"[DRAWER] kicked for cash order #{order.get('id')}") return True except Exception as e: log(f"[DRAWER ERROR] {e}") return False def report_print_failed(order_id, error): """Tell the bridge this ticket could NOT print (W5 print-job ledger). Best-effort: a failure to report must never affect the retry loop — the ticket stays pending and retries either way. Skipped once the bridge has rejected our token: re-POSTing a report it will 401 every poll forever just floods the request log with a symptom while hiding the cause (seen in prod 2026-08-03 — the same order reported every cycle for hours, all 401).""" if _auth_rejected: return try: r = requests.post( f"{RAILWAY_URL}/api/orders/{order_id}/print-failed", headers=auth_headers(), json={"error": str(error)[:300]}, timeout=5, ) _note_bridge_status(r.status_code, "print-failed") except Exception as e: log(f"[REPORT ERROR] order #{order_id}: {e}") def _copy_banner(label): """Centered, emphasized copy label prepended to duplicate receipts.""" return (b"\x1b@" + b"\x1ba\x01" + b"\x1d!\x11" + ("*** %s ***" % label).encode("ascii", "replace") + b"\n" + b"\x1d!\x00" + b"\x1ba\x00") def _print_extra_copies(o): """Owner directive 2026-07-31: three receipts per order, four on delivery. Copy 1 (kitchen) is the unlabeled ticket printed on the critical path above — its success is what marks the order printed. Copies 2..N print here BEST-EFFORT with a banner naming who each is for; a failed extra copy logs loudly but never fails the order or triggers reprint holds (a duplicate-confused kitchen is worse than a missing customer copy). """ labels = ["CUSTOMER COPY", "STORE COPY"] if str(o.get("order_type", "")).lower().startswith("deliver") or \ "delivery" in str(o.get("order_type", "")).lower(): labels.append("DRIVER COPY") # Owner 2026-08-02: no prep SPEC guidance on customer-facing copies. base = build_ticket(o, include_specs=False) oid = o.get("id") or o.get("order_id") for label in labels: try: if not print_receipt_copy(_copy_banner(label) + base, label=f"{label} #{oid}"): log("[COPY ERROR] %s for #%s did not print (%s)" % ( label, oid, _last_print_failure)) except Exception as e: log("[COPY ERROR] %s for #%s: %s" % (label, oid, e)) def process_pending_orders_once(): """Fetch, print, and mark each currently pending order once. Extracted from the infinite loop so setup tests can prove the store-computer spooler path without blocking forever. """ printed_count = 0 for o in fetch_orders(): oid = o.get("id") or o.get("order_id") if not oid: log("[ORDER ERROR] Pending order missing id/order_id; skipping") continue if oid in _printed: continue # Held after MAX_UNCONFIRMED_SENDS sent-but-unconfirmed attempts: do # NOT keep reprinting (each attempt may have physically printed) and do # NOT mark printed (it may never have). Staff were paged at hold time; # the order stays visibly pending for a human to resolve. if oid in _held_unconfirmed: continue # Confirm the printer can actually print BEFORE we send + mark-printed. # A powered printer that is out of paper / cover-open accepts the bytes # and silently drops the ticket; marking it printed would lose the order. # One fault holds this whole batch (all orders share the printer) and # pages staff; the held orders stay pending and retry next poll. fault = check_printer_fault() if fault: _flag_printer_fault(fault) report_print_failed(oid, fault) log(f"[PRINTER FAULT] {fault} — holding #{oid} and the rest of this batch; will retry") break if print_ticket(build_ticket(o)): _unconfirmed_sends.pop(oid, None) if mark_printed_or_warn(oid): _printed.add(oid) printed_count += 1 # Extra copies come AFTER the order is marked printed. Before # this, a failed mark-printed left the order retryable and # every retry sprayed a fresh set of customer/store copies at # the counter. _print_extra_copies(o) maybe_kick_drawer(o) # "Printed" = dispatched to the printer and acked by Railway # (the mark-printed state). Only the completion barrier proves # the paper actually moved; say so when it did. suffix = " (completion-confirmed)" if _last_print_completed else "" log(f"Printed #{o.get('ticket_number','?')} — {o.get('customer_name','')}{suffix}") else: log(f"Printed #{oid}, but Railway did not mark it printed — will retry") else: if _last_send_unconfirmed: # Ticket bytes went out but nothing confirmed them: the ticket # MAY have printed. Retry a bounded number of times (a reprint # beats a silently lost order), then hold and page a human — # unbounded retries could spew duplicates every poll. n = _unconfirmed_sends.get(oid, 0) + 1 _unconfirmed_sends[oid] = n if n >= MAX_UNCONFIRMED_SENDS: _held_unconfirmed.add(oid) reason = (f"held after {n} unconfirmed sends — may have printed " f"{n}x or 0x; needs human check ({_last_print_failure})") report_print_failed(oid, reason) _flag_printer_fault(f"order #{oid} {reason}") log(f"[HOLD] #{oid} sent {n}x without confirmation — no more auto-reprints; staff paged") continue report_print_failed(oid, _last_print_failure or "print_ticket failed (send error)") log(f"Failed #{oid} — will retry") return printed_count def run(): global _last_printer_error if not acquire_spooler_lock(): return False backup = f" | Backup: {BACKUP_PRINTER_IP}:{BACKUP_PRINTER_PORT}" if BACKUP_PRINTER_IP else "" receipt = f" | Receipts: {RECEIPT_PRINTER_IP}:{RECEIPT_PRINTER_PORT}" if RECEIPT_PRINTER_IP else "" log(f"Zorro's Print Agent | Printer: {PRINTER_IP}:{PRINTER_PORT}{backup}{receipt} | Railway: {RAILWAY_URL}") auth_headers() printer_ready = ping_printer() post_heartbeat(printer_reachable=printer_ready, mode="startup") if printer_ready: log("[OK] Printer reachable. Spooler is ready.") else: log("[WARN] Printer is not reachable yet. Orders will retry until the printer is online.") log("Press Ctrl+C to stop\n") while True: try: process_pending_orders_once() process_pending_jobs_once() # A printer that is reachable over TCP but out of paper / cover-open is # NOT ready — report it as not-reachable so the watchdog keeps paging. reachable = ping_printer(timeout=1) fault = check_printer_fault() if reachable else None if fault: _last_printer_error = fault # A locked-out spooler is an outage even when the printer itself is # fine — it will never receive a ticket. Never report it as healthy. if _auth_rejected: _last_printer_error = _auth_rejected post_heartbeat( printer_reachable=(reachable and not fault and not _auth_rejected), mode="polling") except Exception as e: log(f"[ERROR] {e}") time.sleep(POLL_INTERVAL) return True def main(argv=None): args = list(sys.argv[1:] if argv is None else argv) if "--status" in args or "status" in args: return 0 if print_public_status() else 1 if "--test-print" in args or "test-print" in args: return 0 if print_test_ticket() else 1 if "--test-bridge-print" in args or "test-bridge-print" in args: auth_headers() return 0 if print_bridge_test_ticket() else 1 if "--doctor" in args or "doctor" in args: return 0 if print_doctor() else 1 if "--probe-completion" in args or "probe-completion" in args: return 0 if probe_completion() else 1 if "--check" in args or "check" in args: auth_headers() printer_ready = ping_printer() post_heartbeat(printer_reachable=printer_ready, mode="check") return 0 if printer_ready else 1 if "--once" in args or "once" in args: auth_headers() processed = process_pending_orders_once() post_heartbeat(printer_reachable=ping_printer(timeout=1), mode="once") return 0 if processed >= 0 else 1 return 0 if run() else 1 if __name__ == "__main__": raise SystemExit(main())