feat(flomni): add A4 report Print button to sample-storage widget #305

Merged
holler merged 1 commits from feat/flomni-sample-storage-print-report into main 2026-08-20 16:16:34 +02:00
@@ -44,9 +44,14 @@ edit/submit — every mutating action is individually confirmed instead.
from __future__ import annotations
import datetime
import os
import subprocess
import tempfile
from typing import Optional
from bec_lib import bec_logger
from bec_lib.pdf_writer import PDFWriter
from bec_widgets import BECWidget, SafeSlot
from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import (
@@ -57,6 +62,7 @@ from qtpy.QtWidgets import (
QLabel,
QMenu,
QMessageBox,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
@@ -103,6 +109,11 @@ PTOUCH_FIELD_ACCOUNT = "ACCOUNT"
PTOUCH_FIELD_DATE = "DATE"
PTOUCH_FIELD_SAMPLENAME = "SAMPLENAME"
# CUPS queue name for the "Print" button's A4 report. Local to whatever
# machine the GUI runs on -- not guaranteed to exist everywhere, so every
# call site handles the "queue not found" failure instead of pre-checking.
PRINTER_NAME = "WSLA_X12SA"
# ── measured-sample log ──────────────────────────────────────────────────────
@@ -558,6 +569,14 @@ class OMNY_SampleStorage(BECWidget, QWidget):
caveat.setStyleSheet("color: #FF9800; font-size: 11px;")
root.addWidget(caveat)
# button row
button_row = QHBoxLayout()
button_row.addStretch()
self.print_button = QPushButton("Print")
self.print_button.clicked.connect(self._on_print_clicked)
button_row.addWidget(self.print_button)
root.addLayout(button_row)
# ── refresh / poll ────────────────────────────────────────────────────────
@SafeSlot()
@@ -662,6 +681,160 @@ class OMNY_SampleStorage(BECWidget, QWidget):
if self._write_slot(slot, 0, EMPTY_NAME):
self.refresh()
# ── print report ─────────────────────────────────────────────────────────
@SafeSlot()
def _on_print_clicked(self) -> None:
"""Build an A4 report of the current storage state and, after
confirmation, send it straight to the printer -- no preview: `lp`
prints directly from the PDF file, it never needs to be opened.
"""
if not self._printer_available():
QMessageBox.warning(
self,
"Printer not available",
f"Printer '{PRINTER_NAME}' is not available on this machine.",
)
return
try:
pdf_path = self._build_report_pdf()
except Exception as exc:
logger.warning(f"OMNY_SampleStorage: could not build the print report: {exc}")
QMessageBox.critical(self, "Report failed", f"Could not build the report:\n{exc}")
return
reply = QMessageBox.question(
self,
"Print report",
f"Send the sample storage report to printer '{PRINTER_NAME}'?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
self._send_to_printer(pdf_path)
def _printer_available(self) -> bool:
"""Cheap, local check ('lpstat' just queries the local cupsd, no
network round-trip to the physical printer) so an unconfigured
queue on this host gets a plain message instead of the raw CUPS
error `lp` would otherwise raise later.
"""
try:
result = subprocess.run(
["lpstat", "-p", PRINTER_NAME], capture_output=True, text=True, timeout=5
)
return result.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return False
def _build_report_pdf(self) -> str:
"""Render a page mirroring the on-screen grid (stage + gripper
header, 4x5 magazine, same layout/orientation as ``_build_ui()``)
from a fresh device read, and return the path of the temp PDF file
it was written to.
"""
from fpdf import XPos, YPos
state = self._read_all_slots()
fd, path = tempfile.mkstemp(prefix="flomni_sample_storage_", suffix=".pdf")
os.close(fd)
with PDFWriter(path) as file:
pdf = file._pdf
pdf.set_font("Helvetica", "B", 16)
pdf.cell(0, 10, "FlOMNI Sample Storage", new_x=XPos.LMARGIN, new_y=YPos.NEXT, align="C")
pdf.set_font("Helvetica", "", 10)
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
pdf.cell(0, 6, timestamp, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align="C")
pdf.ln(6)
margin = pdf.l_margin
usable_w = pdf.w - 2 * margin
cell_w = usable_w / STORAGE_COLS
cell_h = 28
# header: stage + gripper, same widths as two magazine cells
y0 = pdf.get_y()
self._draw_report_cell(pdf, margin, y0, cell_w, cell_h, STAGE_SLOT, state.get(STAGE_SLOT))
self._draw_report_cell(
pdf, margin + cell_w, y0, cell_w, cell_h, GRIPPER_SLOT, state.get(GRIPPER_SLOT)
)
# magazine grid, same row/col + right-to-left mirroring as the
# on-screen grid (sample_storage.py's _build_ui())
grid_y0 = y0 + cell_h + 4
for idx, slot in enumerate(STORAGE_SLOTS):
r, c = divmod(idx, STORAGE_COLS)
c = STORAGE_COLS - 1 - c
x = margin + c * cell_w
y = grid_y0 + r * cell_h
self._draw_report_cell(pdf, x, y, cell_w, cell_h, slot, state.get(slot))
return path
def _draw_report_cell(self, pdf, x, y, w, h, slot, entry) -> None:
"""Draw one slot's box, mirroring what ``_SlotCell.set_state()``
shows on screen: slot caption, name (or "empty"), owner and measured
marker.
"""
pdf.rect(x, y, w, h)
occupied, name, owner, measured_status = entry if entry else (False, EMPTY_NAME, "", None)
pdf.set_xy(x + 1, y + 1)
pdf.set_font("Helvetica", "", 7)
pdf.cell(w - 2, 4, self._slot_title(slot), align="L")
if not occupied:
pdf.set_xy(x + 1, y + h / 2 - 2)
pdf.set_font("Helvetica", "I", 8)
pdf.cell(w - 2, 4, "empty", align="C")
return
pdf.set_xy(x + 1, y + 7)
pdf.set_font("Helvetica", "B", 9)
pdf.multi_cell(w - 2, 4, name, align="C")
pdf.set_xy(x + 1, y + h - 12)
pdf.set_font("Helvetica", "", 7)
pdf.cell(w - 2, 4, f"owner: {owner}" if owner else "", align="C")
marker = ""
if measured_status == "completed":
marker = "✓ measured"
elif measured_status == "started":
marker = "◐ started"
pdf.set_xy(x + 1, y + h - 6)
pdf.set_font("Helvetica", "", 7)
pdf.cell(w - 2, 4, marker, align="C")
def _send_to_printer(self, pdf_path: str) -> None:
"""Send an already-built PDF to ``PRINTER_NAME`` via CUPS, offering
Retry/Ignore on failure -- mirrors
``_offer_print_label_for_previous_occupant()``'s fail-soft retry
loop for the P-touch label printer, since this queue is just as
likely to be missing/offline on any given machine.
"""
while True:
try:
subprocess.run(
["lp", "-d", PRINTER_NAME, pdf_path],
check=True,
capture_output=True,
text=True,
timeout=15,
)
return
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired) as exc:
if isinstance(exc, subprocess.CalledProcessError) and exc.stderr:
detail = exc.stderr.strip()
else:
detail = str(exc)
reply = QMessageBox.warning(
self,
"Print failed",
f"Could not send the report to printer '{PRINTER_NAME}':\n{detail}",
QMessageBox.StandardButton.Retry | QMessageBox.StandardButton.Ignore,
)
if reply != QMessageBox.StandardButton.Retry:
return
# ── cleanup ───────────────────────────────────────────────────────────────
def cleanup(self) -> None: