diff --git a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py index b47b4fa2..ac1c1304 100644 --- a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py +++ b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py @@ -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: