diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/flomni.lbx b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/flomni.lbx new file mode 100644 index 00000000..301f33ca Binary files /dev/null and b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/flomni.lbx differ diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/lamni.lbx b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/lamni.lbx new file mode 100644 index 00000000..49755977 Binary files /dev/null and b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/lamni.lbx differ diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny.lbx b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny.lbx new file mode 100644 index 00000000..679532a8 Binary files /dev/null and b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny.lbx differ 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 diff --git a/docs/developer/cv_ptp900_eng_ptemp_103.pdf b/docs/developer/cv_ptp900_eng_ptemp_103.pdf new file mode 100644 index 00000000..f77179ff Binary files /dev/null and b/docs/developer/cv_ptp900_eng_ptemp_103.pdf differ diff --git a/docs/developer/ptouch_label_printer_plan.md b/docs/developer/ptouch_label_printer_plan.md new file mode 100644 index 00000000..134628a7 --- /dev/null +++ b/docs/developer/ptouch_label_printer_plan.md @@ -0,0 +1,166 @@ +# Brother P-touch PT-P950NW label printing for BEC: design plan + +> **Status: proposal, not yet implemented.** This document captures the plan +> from an initial feasibility/design session so it isn't lost; it should be +> updated (or removed / folded into an architecture note) once the feature is +> actually built. + +## Context + +When a measured sample is unloaded from the flomni sample tray, the only record +of "who owns it / when it was measured" today is digital (tray-slot metadata +in `dev.flomni_samples`, plus the OMNY sample database via `TomoIDManager`). +There is no physical label on the actual sample holder, so a user picking it +up later has no way to tell at a glance whose sample it is or when it was +run. The goal of this feature is to print a small paper label (account + +date, on TZ tape) at the moment a measured sample is stowed back in the tray, +so it can be stuck straight onto the sample holder. + +This is explicitly **not** an experiment hardware component (no motion, no +staging, nothing that needs to be part of a scan) — it's a side-effect +utility, similar in spirit to the existing `TomoIDManager` (HTTP registration +call) and `HttpUploader` (webpage push) helpers, which are plain Python +classes invoked from the flomni/lamni plugin code at well-defined workflow +points, not ophyd devices. + +**Decisions already made:** +- Protocol: **P-touch Template mode** (Brother's official, documented + command protocol) rather than the raster/reverse-engineered protocol used + by third-party libraries like `rasterprynt`. This avoids needing an image + library (Pillow) client-side and avoids depending on an undocumented + protocol that could break on a firmware update — fits the goal of + minimizing external packages. +- Scope: build as a **shared utility** in + `bec_ipython_client/plugins/OMNY_shared/`, so lamni/omny can adopt it later + without duplicating printer/network code — mirrors where `TomoIDManager` + and `HttpUploader` already live. +- Trigger: **both**. Automatic prompt-then-print on sample unmount in flomni, + plus a manual/standalone command for on-demand (re-)printing. + +## How P-touch Template mode works (research summary) + +- The PT-P950NW supports raw network printing on **TCP port 9100** (listed + in its spec sheet alongside LPR/IPP/mDNS etc.), the same "JetDirect-style" + raw port most network printers use — no CUPS/driver install needed. +- "P-touch Template" is a printer-side mode built for exactly this use case + (the manual explicitly lists "data from a scale, testing machine, + controller, or PLC" as the target): you design a label layout **once** in + Brother's Windows-only **P-touch Editor** (place named text objects, e.g. + `ACCOUNT`, `DATE`), transfer that template onto the printer's internal + memory (via Editor's Transfer Manager over USB or network), and from then + on printing is just: connect to the printer, send a short **ESC i X** + command sequence selecting the template number and supplying the text for + each named field, then a print-start command. +- The command set is documented in Brother's "P-touch Template Command + Reference" (per-printer-family PDF from Brother's support site — confirm + the PT-P900W/P950NW-specific revision before implementing, since control + codes can differ slightly between printer families/firmware generations). +- No actively-maintained Python package implements this specific protocol + (unlike raster printing, which has `rasterprynt`/`brother_ql`-style + libraries). That's fine here: the runtime protocol is just building a byte + string and pushing it over a `socket` — stdlib only, no new dependency. + +## Implementation plan + +### 1. New shared module: `PTouchLabelPrinter` + +File: `csaxs_bec/bec_ipython_client/plugins/OMNY_shared/ptouch_printer.py` +(new file, alongside `omny_general_tools.py`/`web_common.py`). + +```python +class PTouchLabelPrinter: + """Prints a label on a Brother PT-P950NW via P-touch Template mode.""" + + DEFAULT_PORT = 9100 + + def __init__(self, host: str, template_number: int = 1, port: int = DEFAULT_PORT, timeout: float = 3.0): + ... + + def print_label(self, fields: dict[str, str], copies: int = 1) -> bool: + """Build the P-touch Template command for `fields` and send it. + + Returns True on success, False on any network/printer error -- + never raises, matching TomoIDManager's fail-soft convention so a + printer being offline can never block a sample transfer. + """ + ... +``` + +- Builds the ESC-prefixed command sequence (select template mode, select + template number, set each named field's text, set copy count, print + command) as pure bytes, opens a short-lived `socket.create_connection((host, + port), timeout=timeout)`, sends, closes. +- Wrap all I/O in `try/except`, `logger.warning(...)` on failure, return + `False` — same fail-soft style as `TomoIDManager.register()` + (`omny_general_tools.py:396-`) and `HttpUploader` (`web_common.py:48-114`). +- Printer host/IP and template number are constructor args (not hardcoded + constants), passed in from each plugin's config — check how other + beamline-specific endpoints are configured in this repo (e.g. is there a + per-instrument config file, or are they class constants like + `TomoIDManager.OMNY_URL`?) and follow that convention when wiring it up. + +### 2. Wire into flomni + +File: `csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py` + +- Instantiate once in `__init__`, next to `self.OMNYTools = OMNYTools(...)` + (line ~1654) and `self.tomo_id_manager = TomoIDManager()`: + `self.label_printer = PTouchLabelPrinter(host=, ...)`. +- **Automatic path**: in the unmount branch of `ftransfer_sample_change` + (the `new_sample_position == -1` case, `flomni.py:867-906`) or in + `ftransfer_get_sample` (`flomni.py:734`, name/owner already unpacked at + line 786-787 as `name, owner = unpack_desc(signal_name.get())`), after the + stow completes: ask `self.OMNYTools.yesno("Print a label for this sample?", + "y")` (same helper already used elsewhere, e.g. `flomni.py:2507`) and on + `True` call `self.label_printer.print_label({"ACCOUNT": owner, "DATE": + str(datetime.date.today())})`. +- **Manual path**: add a small method, e.g. `fprint_sample_label(position: + int)`, that reads name/owner via the same `unpack_desc(...)` pattern as + `sample_get_name` (`flomni.py:853`) and calls `label_printer.print_label` + directly — usable standalone for reprints, without going through a + transfer. + +### 3. Open items to resolve before/during implementation + +- **No date is currently stored per sample slot** — `pack_desc`/`unpack_desc` + (`devices/omny/sample_desc_codec.py:19,40`) only carry `(name, owner)`, no + timestamp. Using `datetime.date.today()` at unmount time is a reasonable + v1 approximation (unmount typically happens same-day as the scan), but if + an exact measurement date is wanted later, the DESC codec would need a + third field. +- **Confirm the exact model-specific command reference.** Brother publishes + a separate "P-touch Template Command Reference" per printer family; + download the PT-P900W/P950NW-specific PDF from Brother's support site + before writing the byte-level command builder. +- **Template design is a one-time, Windows-only step** (P-touch Editor + + Transfer Manager) — not scriptable from the Linux beamline control host. + Worth doing once per label layout/TZ tape width; layout changes require + redoing this step. +- **Network reachability**: confirm the printer's IP is reachable from + wherever `bec_ipython_client` runs (same VLAN/firewall rules as other + network endpoints it already calls, e.g. the OMNY/tomo-ID server) and + assign it a static/reserved IP. +- Extend to lamni/omny by instantiating `PTouchLabelPrinter` in those + plugins too once flomni is validated — no code changes needed to the + shared module itself. + +## Verification + +1. **Bench test the printer/template alone**: install the PT-P950NW on the + beamline network with a fixed IP, design the label template once in + P-touch Editor (fields `ACCOUNT`/`DATE`, chosen TZ tape width), load it + via Transfer Manager, and print a manual test label from the desktop app + to confirm the template renders as expected. +2. **Bench test the protocol module standalone**: write + `PTouchLabelPrinter`, then from a scratch script call + `print_label({"ACCOUNT": "e12345", "DATE": "2026-07-31"})` against the + real printer IP and confirm a correct physical label comes out (check + encoding, field placement, tape length/cut). +3. **Integration test in flomni**: in a real or simulated + `bec_ipython_client` session, run `ftransfer_sample_change(-1)` (or + `ftransfer_get_sample`) end-to-end, confirm the yes/no prompt appears, + confirm printing on "y", confirm declining or a deliberately-unreachable + printer IP does **not** raise or block the sample transfer (only logs a + warning). +4. Test the manual `fprint_sample_label(position)` command independently of + any transfer, for reprints.