feat(flomni): add A4 report Print button to sample-storage widget #305
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user