From 0ddab287c3eb85a4f7ba83014f8a64ff0c0cde6e Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 19 Aug 2026 13:50:46 +0200 Subject: [PATCH] feat(flomni): add PTouchLabelPrinter for Brother PT-P950NW labels Fills the named text objects of a pre-transferred P-touch Template and triggers a print over raw TCP:9100, validated end-to-end against the real printer at BRN94DDF8AAB8EC.psi.ch. Shared OMNY_shared utility, not an ophyd device, per docs/developer/ptouch_label_printer_plan.md. Not yet wired into flomni.py -- that's the next step. Co-Authored-By: Claude Sonnet 5 --- .../plugins/OMNY_shared/ptouch_printer.py | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 csaxs_bec/bec_ipython_client/plugins/OMNY_shared/ptouch_printer.py diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/ptouch_printer.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/ptouch_printer.py new file mode 100644 index 00000000..abb4f181 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/ptouch_printer.py @@ -0,0 +1,146 @@ +""" +Prints account/date labels on a Brother PT-P950NW via the P-touch Template +protocol (raw bytes over TCP:9100), for the sample-holder labeling feature +described in docs/developer/ptouch_label_printer_plan.md. + +Reference: Brother's "P-touch Template Command Reference" +(docs/developer/cv_ptp900_eng_ptemp_103.pdf), PT-P900/PT-P900W/PT-P950NW +v1.03. Byte sequences below were validated against a real PT-P950NW at +BRN94DDF8AAB8EC.psi.ch. +""" + +from __future__ import annotations + +import socket + +from bec_lib import bec_logger + +logger = bec_logger.logger + + +class PTouchLabelPrinter: + """Prints a label on a Brother PT-P950NW via P-touch Template mode. + + The label template (named text objects, tape width, cut settings) is + designed once in Brother's P-touch Editor and transferred to the + printer's internal memory via P-touch Transfer Manager -- a manual, + Windows-only, one-time step, not something this class does. This class + only fills in the named text objects of whichever template is already + on the printer and triggers a print. + + Fail-soft by design (mirrors TomoIDManager.register() in + omny_general_tools.py): any network/printer error is caught and logged + as a warning, never raised, so a printer being offline or unreachable + can never block a sample transfer. + + Cut settings are fixed to half-cut on / full-cut off / chain printing on + (bench-tested combination: clean peel per label, no wasteful full + separation, no extra feed gap between consecutive labels). With chain + printing always on, the *last* label of a run is left uncut on the tape + until someone presses the printer's physical cut button. + + Usage: + printer = PTouchLabelPrinter(host="BRN94DDF8AAB8EC.psi.ch", template_number=1) + printer.print_label({"ACCOUNT": "e12345", "DATE": "2026-08"}) + """ + + DEFAULT_PORT = 9100 + + # ESC i a -- specify command mode; 0x03 = P-touch Template mode. + _FORCE_TEMPLATE_MODE = bytes([0x1B, 0x69, 0x61, 0x03]) + + # Bench-tested cut settings (PDF p.49-51): half cut on (^CH1), full cut + # off (^CF00), chain printing on (^CP1) -- see class docstring. + _CUT_SETTINGS = b"^CH1^CF00^CP1" + + def __init__( + self, + host: str, + template_number: int = 1, + port: int = DEFAULT_PORT, + timeout: float = 3.0, + ): + self.host = host + self.template_number = template_number + self.port = port + self.timeout = timeout + + def _build_command(self, fields: dict[str, str], copies: int = 1) -> bytes: + cmd = bytearray() + cmd += self._FORCE_TEMPLATE_MODE + cmd += b"^II" # initialize + + # ^TS: n1 is a fixed '0', n2n3 is the 2-digit template + # number -- three digits total after "^TS" (PDF p.39 example: + # "^TS099" for template 99). Sending only two digits corrupts the + # next command, since ^TS always consumes exactly three digit bytes. + cmd += f"^TS0{self.template_number:02d}".encode("ascii") + + for name, value in fields.items(): + cmd += b"^ON" + name.encode("ascii") + b"\x00" + text = value.encode("ascii") + n = len(text) + cmd += b"^DI" + bytes([n & 0xFF, (n >> 8) & 0xFF]) + text + + cmd += self._CUT_SETTINGS + + if copies != 1: + cmd += f"^CN{copies:03d}".encode("ascii") + + cmd += b"^FF" # start printing (default "Command Character" trigger) + return bytes(cmd) + + def print_label(self, fields: dict[str, str], copies: int = 1) -> bool: + """Fill in `fields` on the printer's current template and print it. + + Returns True on success, False on any network/printer error -- + never raises, so an offline printer can't block a sample transfer. + """ + command = self._build_command(fields, copies) + try: + with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock: + sock.sendall(command) + except OSError as exc: + logger.warning( + f"Could not print label on {self.host}:{self.port} ({exc}); " + "label was not printed." + ) + return False + return True + + def is_online(self) -> bool: + """Check whether the printer accepts a TCP connection. + + Reachability only. The ^SR status-request command (PDF p.55-60, + which would otherwise report tape width/type and mechanical + errors) gets no reply at all over TCP:9100 on this printer -- + confirmed by direct testing, from two different hosts, after first + forcing P-touch Template mode. Its network "raw port" appears to be + a write-only pass-through to the print engine, so tape/error status + can't be queried over this transport at all. Never raises. + """ + try: + with socket.create_connection((self.host, self.port), timeout=self.timeout): + pass + except OSError: + return False + return True + + def ensure_ready(self, prompt_fn=input) -> bool: + """Block, prompting the operator, until the printer is reachable. + + Reachability only -- see is_online(). Since tape/error status can't + be queried, the prompt also reminds the operator to check tape + physically. Returns True once reachable, or False if the operator + answers "skip". + """ + while True: + if self.is_online(): + return True + message = ( + f"Printer at {self.host} is not reachable. Please turn it on " + "and make sure it has tape loaded, then press Enter to retry " + "(or type 'skip'): " + ) + if prompt_fn(message).strip().lower() == "skip": + return False