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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user