Feat/ptouch #303
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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><n2><n3>: 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
|
||||
Binary file not shown.
@@ -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=<printer-ip>, ...)`.
|
||||
- **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.
|
||||
Reference in New Issue
Block a user