#!/usr/bin/env python3
"""Present the appliance's USB printer as an ordinary network printer.

WHY A BRIDGE INSTEAD OF PRINTING FROM THE APP.

Everything Maestro knows about printing is built around a printer with an
address and a port: the station→printer bindings, the routing that decides which
till or kitchen a docket goes to, the DLE EOT health probe that catches a
printer which is out of paper rather than merely reachable, and the badge on the
POS that surfaces it. A USB printer wired straight into the KDS app would have
none of that — it would be a second printing path, with its own bugs, its own
silence when it failed, and nothing in the printer screen to configure.

So the appliance speaks the protocol the rest of the system already speaks: raw
ESC/POS over TCP 9100. Add the KDS's own address as a printer, bind it to the
kitchen station, and the node's print-manager drives it exactly like any network
printer. The fact that it is USB on the inside stops being anybody else's
problem — including the fix for deleted station bindings and the health probe,
both of which it inherits for free.

STATUS WITHOUT TOUCHING THE PARSER. The health probe (DLE EOT) exists because
printers fail silently — but on THIS module the probe itself is the poison: any
DLE EOT that physically reaches the firmware, even while completely idle and
correctly answered, corrupts the print engine's feed state, and the next cut
runs away feeding blank paper until the unit is cold power-cycled. Proven by
elimination on 2026-08-19: a cold-booted printer prints and cuts perfectly
until the first probe byte arrives; the very next job after any probe runs
away. An entire roll died establishing this.

So NO DLE EOT EVER REACHES THE DEVICE. The bridge answers every probe itself,
synthesized from the USB printer class's out-of-band status (LPGETSTATUS
ioctl → control-endpoint GET_PORT_STATUS): paper-out, select and error bits
read from the hardware line without one byte entering the parser. Callers get
honest, current answers; the parser gets silence. Detection of a dead or
paperless printer therefore still works — it just no longer costs a roll.
"""
import errno
import os
import selectors
import signal
import socket
import sys
import threading
import time

DEVICE_CANDIDATES = ("/dev/maestro-printer", "/dev/usb/lp0", "/dev/usb/lp1",
                     "/dev/usb/lp2")
LISTEN_HOST = "0.0.0.0"
LISTEN_PORT = 9100

# One connection prints at a time. A thermal printer has one paper path: two
# dockets interleaved byte-wise produce one ruined receipt and no error at all.
_print_lock = threading.Lock()

# THE DEVICE IS OPENED AND CLOSED PER JOB. THIS IS NOT AN OVERSIGHT.
#
# On usblp, CLOSING the fd is what completes the transfer to the printer — and
# the cut command is always the last bytes of a receipt, so the close is what
# fires the cutter. Evidence, in order:
#
#   * open-per-job, close at the end   → 35KB ticket printed AND cut
#   * fd held open across jobs         → same 35KB ticket printed, never cut
#
# I broke this myself by "fixing" a big-receipt failure with a persistent fd,
# which silently removed the only thing that ever made the cutter fire. Two
# receipts printed back to back with no cut and a metre of blank paper between
# them is what that looks like.
#
# Do not hold this fd open. If a long job misbehaves the answer is pacing (see
# CHUNK/BYTES_PER_SEC), not keeping the device open.
_dev_fd = None
_dev_path_open = None

# PACE THE WRITES. This is the whole reason big receipts failed.
#
# The print-manager was built for NETWORK printers, where TCP backpressure
# paces the sender against a slow print head. Over USB there is no such
# throttle: 83KB is accepted by the endpoint in a fraction of a second and
# handed to a head that prints at roughly 25-30KB/s. The buffer overruns, rows
# are lost mid-raster, and because GS v 0 declares its height up front the
# printer then feeds blank paper hunting for the rows that never came — and
# swallows the trailing cut command as image data.
#
# The symptom was therefore SIZE-DEPENDENT and looked like a printer quirk: a
# 35KB ticket printed and cut perfectly, an 83KB one printed every line and then
# spooled out half a metre of blank paper. Two earlier "fixes" (a drain before
# close, then holding the fd open) changed nothing, because neither addressed
# the overrun.
#
# 2KB at a time, paced to the head's TRUE speed. The original 25KB/s figure
# came from a different printer; this module physically prints ~9KB/s (a 44KB
# ticket takes ~5s of feeds+raster+cut — measured 2026-08-19). At 25KB/s a
# single job barely fits in the internal buffer and BACK-TO-BACK jobs overrun
# it every time: rows vanish mid-raster, the printer feeds blank paper hunting
# for them, and the trailing cut is swallowed as image data — exactly the
# volley failure (two uncut tickets, 40cm of blank). Pacing at/below head
# speed keeps the buffer near-empty so the close always finds the cut intact.
# THIS FIRMWARE PUNISHES BOTH DIRECTIONS. Too fast into a non-empty buffer:
# rows vanish, it hunts, cuts get swallowed. Too slow (tried 6-8.5KB/s): the
# head STARVES mid-raster and the firmware feeds blank paper while waiting
# for the late rows — blank gaps inside tickets, blank runs between them.
# The only proven-safe move is the solo pattern: burst the whole ticket at
# 25KB/s into an EMPTY buffer (every clean print of 2026-08-19 was exactly
# this), and let the solo-hold below guarantee the buffer is always empty
# when a job starts. Do not "gentle" this number down again — slow is poison.
CHUNK = 2048
BYTES_PER_SEC = 25000.0

# NO-CUT MODE (marker file, delete to restore cutting). Every failure mode of
# this module involved a CUT CYCLE: solo prints are flawless, and anything
# arriving in the same era as a cut trips the firmware into hunt-and-feed.
# With cuts stripped, tickets separate at the tooth border by hand — classic
# kitchen docket tear-off. The trailing GS V A 0 is held back via a 4-byte
# lag buffer and dropped at end-of-job; anything else in the lag is flushed.
NO_CUT_FLAG = "/etc/maestro-printer-nocut"
CUT_CMD = b"\x1d\x56\x41\x00"

# CUT-REWRITE MODE: the module's own Windows driver (JRSVC_Printer.zip,
# POS80D.dll, disassembled 2026-08-19) NEVER cuts with GS V A — it cuts with
# GS V B (0x42) and never sends a single DLE EOT (it reads status via GS r).
# Function A is what corrupts this firmware. With this flag the bridge
# rewrites the manager's trailing GS V A 0 into the driver's own GS V B 0 —
# real cuts, in the only grammar the firmware provably supports.
CUT_B_FLAG = "/etc/maestro-printer-cutb"
CUT_B_CMD = b"\x1d\x56\x42\x00"

# HYBRID PACING: the internal buffer is ~48KB (44KB tickets burst clean at
# 25KB/s; 60KB tickets overran). Burst the first PREFILL bytes at line speed
# to build a cushion, then feed at head speed so the buffer neither starves
# (blank-feed hunts) nor overruns (lost rows), whatever the ticket size.
PREFILL_BYTES = 32768

# After the device fd closes (which commits the job and fires the cutter) the
# head keeps PHYSICALLY printing from its buffer — for seconds, not moments: a
# 44KB one-item ticket takes ~5s of feeds, raster and cut after the close. A
# flat 3s window let probes through into a printer that was still deaf, and
# they died exactly like before the window existed ("stopped answering",
# 503, retry). So the window scales with job size (~9KB/s of effective head
# throughput, measured on this unit) and ACCUMULATES across back-to-back
# jobs, capped so a bad estimate can never wedge the honest pass-through for
# long. Probes inside the window are served from cache: alive but deaf.
HEAD_BPS = 9000.0
_busy_until = 0.0

# Out-of-band status: usblp's LPGETSTATUS ioctl issues a USB printer-class
# GET_PORT_STATUS on the control endpoint. Centronics-style bits come back:
# 0x20 paper empty, 0x10 selected, 0x08 not-error. Nothing is written to the
# print parser. Refreshed only when the paper path is free; the last reading
# is replayed while a job is printing (the device is provably alive then).
LPGETSTATUS = 0x060B
_lp_last = None  # last raw status byte, None until first successful read


def _lp_status():
    """Refresh the out-of-band port status when safe; else the last reading."""
    global _lp_last
    if _print_lock.locked() or time.monotonic() < _busy_until:
        return _lp_last
    path = find_device()
    if path is None:
        return None
    try:
        fd = os.open(path, os.O_RDONLY)
    except OSError:
        try:
            fd = os.open(path, os.O_RDWR)
        except OSError:
            return None
    try:
        import fcntl
        import struct
        raw = fcntl.ioctl(fd, LPGETSTATUS, struct.pack("i", 0))
        _lp_last = struct.unpack("i", raw)[0] & 0xFF
    except OSError:
        pass  # ioctl unsupported: keep whatever we had
    finally:
        try:
            os.close(fd)
        except OSError:
            pass
    return _lp_last


def probe_answer(n):
    """A DLE EOT n reply synthesized from the out-of-band hardware status."""
    st = _lp_status()
    paper_out = st is not None and bool(st & 0x20)
    offline = st is not None and not (st & 0x10)
    error = st is not None and not (st & 0x08)
    if n == 1:
        return bytes([0x16 | (0x08 if (offline or paper_out or error) else 0)])
    if n == 2:
        return bytes([0x12 | (0x20 if paper_out else 0) | (0x40 if error else 0)])
    if n == 3:
        return bytes([0x12 | (0x40 if error else 0)])
    if n == 4:
        return bytes([0x72]) if paper_out else bytes([0x12])
    return b"\x12"


# Both proven drivers for this module family — the vendor's own Windows
# driver (POS80D.dll) and the Linux zj-80 CUPS filter — send raster in SMALL
# BLOCKS (zj: 24 lines, ~1.7KB) so the parser sits at a safe command boundary
# every few centimetres, and NEITHER ever cuts with GS V A (Windows: GS V B;
# zj: GS V 1 — the cutter is physically a partial cutter). The print-manager
# sends one monolithic 44-60KB GS v 0 and a GS V A cut: shape-alien to this
# firmware. This transform makes the wire stream shape-identical to the
# proven drivers without touching the manager: re-chunk every GS v 0 into
# 24-row blocks and rewrite the trailing cut.
RASTER_ROWS_PER_BLOCK = 24


def transform_job(job, no_cut, cut_b):
    out = bytearray()
    i, n = 0, len(job)
    while i < n:
        j = job.find(b"\x1d\x76\x30", i)
        if j < 0:
            out += job[i:]
            break
        out += job[i:j]
        if j + 8 > n:
            out += job[j:]  # truncated header: pass through untouched
            break
        m, xL, xH, yL, yH = job[j + 3], job[j + 4], job[j + 5], job[j + 6], job[j + 7]
        X, Y = xL + 256 * xH, yL + 256 * yH
        start, end = j + 8, j + 8 + X * Y
        if X == 0 or end > n:
            out += job[j:]  # malformed: pass through untouched
            break
        r = 0
        while r < Y:
            rows = min(RASTER_ROWS_PER_BLOCK, Y - r)
            out += bytes([0x1D, 0x76, 0x30, m, xL, xH, rows, 0])
            s = start + r * X
            out += job[s:s + rows * X]
            r += rows
        i = end
    # Trailer surgery: the manager always ends with GS V A 0.
    if bytes(out[-4:]) == CUT_CMD:
        del out[-4:]
        if cut_b:
            out += CUT_B_CMD
        elif not no_cut:
            out += CUT_B_CMD  # function A never goes to this firmware
    return bytes(out)


def probe_queries(data):
    """The DLE EOT n values contained in a status-probe payload."""
    return [data[i + 2] for i in range(len(data) - 2)
            if data[i:i + 2] == b"\x10\x04"]


def log(msg):
    sys.stderr.write(f"maestro-printer-bridge: {msg}\n")
    sys.stderr.flush()


def find_device():
    for path in DEVICE_CANDIDATES:
        if os.path.exists(path):
            return path
    return None


def close_device():
    global _dev_fd, _dev_path_open
    if _dev_fd is not None:
        try:
            os.close(_dev_fd)
        except OSError:
            pass
    _dev_fd, _dev_path_open = None, None


def open_device():
    """The shared printer fd, opened on demand and kept for the process."""
    global _dev_fd, _dev_path_open
    path = find_device()
    if path is None:
        close_device()
        return None, None
    # Re-enumerated under a different node (unplug/replug): drop the stale fd.
    if _dev_fd is not None and _dev_path_open != path:
        close_device()
    if _dev_fd is None:
        try:
            _dev_fd = os.open(path, os.O_RDWR)
        except OSError:
            try:
                _dev_fd = os.open(path, os.O_WRONLY)
            except OSError as e:
                log(f"cannot open {path}: {e}")
                return None, None
        _dev_path_open = path
    return _dev_fd, path


def handle(conn, addr):
    global _busy_until
    if find_device() is None:
        log(f"{addr}: no printer device present, dropping")
        conn.close()
        return

    # Classify BEFORE touching the paper path: read the opening bytes. A
    # status probe is three-ish bytes and must never queue behind a printing
    # job — that wait IS the timeout that marked a working printer offline.
    conn.settimeout(3.0)
    try:
        first = conn.recv(8192)
    except socket.timeout:
        first = b""
    except OSError:
        try:
            conn.close()
        except OSError:
            pass
        return

    queries = probe_queries(first) if 0 < len(first) <= 8 else []

    # EVERY probe is answered here, from the out-of-band hardware status —
    # DLE EOT bytes NEVER reach the parser (see header: they poison it).
    #
    # DO NOT CLOSE THE CONNECTION between answers. The print-manager probes
    # and then prints on the SAME socket; hanging up after the answer broke
    # the manager mid-send. And the /printer-status endpoint sends THREE
    # queries in sequence (n=1, then 2, then 4), each only after reading the
    # previous reply — so this must LOOP. The first version classified only
    # the first batch and pumped the follow-up queries into the device as
    # "payload": 6 bytes of DLE EOT straight into the parser, runaway feed
    # resumed. Loop until real payload arrives or the caller hangs up.
    while queries:
        reply = b"".join(probe_answer(n) for n in queries)
        try:
            conn.sendall(reply)
        except OSError:
            try:
                conn.close()
            except OSError:
                pass
            return
        log(f"{addr}: probe answered out-of-band (parser untouched)")
        conn.settimeout(0.35)
        try:
            first = conn.recv(8192)
        except socket.timeout:
            first = b""  # caller quiet: fall through, the loop sorts it out
        except OSError:
            first = b""
        else:
            if not first:
                # A bare health probe: answered, caller hung up — done,
                # without ever touching the paper path.
                try:
                    conn.close()
                except OSError:
                    pass
                return
        queries = probe_queries(first) if 0 < len(first) <= 8 else []
    # `first` now holds real payload (or nothing): the job path takes over.

    with _print_lock:
        dev = None
        sent = 0
        no_cut = os.path.exists(NO_CUT_FLAG)
        cut_b = os.path.exists(CUT_B_FLAG)
        try:
            dev_path = find_device()
            if dev_path is None:
                log(f"{addr}: printer unavailable")
                conn.close()
                return
            try:
                dev = os.open(dev_path, os.O_RDWR)
            except OSError:
                dev = os.open(dev_path, os.O_WRONLY)

            # BUFFER THE WHOLE JOB, transform once, then write. The manager
            # never waits on the device (its bytes sit in socket buffers and
            # its call returns on close), so buffering costs nothing — and
            # the transform needs the complete raster to re-chunk it.
            job_buf = bytearray(first)
            conn.settimeout(0.25)
            idle = 0.0
            while True:
                try:
                    data = conn.recv(65536)
                except socket.timeout:
                    idle += 0.25
                    if idle >= 5.0:
                        break  # half-open caller must not pin the paper path
                    continue
                except OSError:
                    break
                if not data:
                    break  # caller closed: job complete
                idle = 0.0
                if not job_buf:
                    # Late probe queries before any payload: answer them
                    # out-of-band and drop them — never into the parser.
                    while len(data) >= 3 and data[:2] == b"\x10\x04":
                        try:
                            conn.sendall(probe_answer(data[2]))
                        except OSError:
                            pass
                        log(f"{addr}: late probe answered out-of-band")
                        data = data[3:]
                    if not data:
                        continue
                job_buf += data
                if len(job_buf) > 1048576:
                    log(f"{addr}: job exceeds 1MB, aborting")
                    job_buf = bytearray()
                    break

            if job_buf:
                payload = transform_job(bytes(job_buf), no_cut, cut_b)
                if len(payload) != len(job_buf):
                    log(f"{addr}: transformed {len(job_buf)} -> {len(payload)} "
                        f"(24-row blocks, cut policy applied)")
                try:
                    with open("/var/tmp/maestro-last-job.bin", "wb") as f:
                        f.write(payload)
                except OSError:
                    pass
                off = 0
                stalls = 0
                while off < len(payload):
                    chunk_end = min(off + CHUNK, len(payload))
                    try:
                        n = os.write(dev, payload[off:chunk_end])
                        stalls = 0
                    except OSError as werr:
                        # Buffer full is not a dead printer: wait and retry
                        # the SAME bytes — aborting drops the cut.
                        if werr.errno in (errno.EIO, errno.EAGAIN,
                                          errno.ENOBUFS, errno.ENOSPC) and stalls < 600:
                            stalls += 1
                            time.sleep(0.1)
                            continue
                        log(f"{addr}: write to printer failed: {werr}")
                        break
                    off += n
                    # Prefill at the proven wire rate, then match the head so
                    # the buffer neither starves nor overruns.
                    rate = BYTES_PER_SEC if off <= PREFILL_BYTES else HEAD_BPS
                    time.sleep(n / rate)
                sent = off
        except OSError as e:
            if e.errno not in (errno.ECONNRESET, errno.EPIPE):
                log(f"{addr}: socket error: {e}")
        finally:
            # CLOSE THE PRINTER FD. On usblp the close is what commits the
            # job and fires the cutter.
            if dev is not None:
                try:
                    os.close(dev)
                except OSError:
                    pass
            try:
                conn.close()
            except OSError:
                pass
            if sent > 64:
                # Deaf window + solo hold: the head keeps printing after the
                # close; the next job must meet an IDLE printer.
                base = max(_busy_until, time.monotonic())
                _busy_until = min(base + 1.0 + sent / HEAD_BPS,
                                  time.monotonic() + 30.0)
                time.sleep(min(sent / 6500.0 + 1.5, 15.0))
            log(f"{addr}: {sent} bytes printed")


def main():
    signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
    srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    try:
        srv.bind((LISTEN_HOST, LISTEN_PORT))
    except OSError as e:
        log(f"cannot bind {LISTEN_HOST}:{LISTEN_PORT}: {e}")
        return 1
    srv.listen(8)
    log(f"listening on {LISTEN_HOST}:{LISTEN_PORT}, device {find_device()}")

    while True:
        try:
            conn, addr = srv.accept()
        except OSError as e:
            log(f"accept failed: {e}")
            continue
        # A thread per job: printing is slow and the accept loop must stay
        # responsive, but the lock keeps the paper path serialised.
        threading.Thread(target=handle, args=(conn, f"{addr[0]}:{addr[1]}"),
                         daemon=True).start()


if __name__ == "__main__":
    sys.exit(main())
