mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:47:03 +01:00
usb_query.py 267 -> 180. The non-blocking fd handling - _drain_buffer, _write_with_deadline, _read_nonblocking, _wait_for_pjl_response and pjl_query - moves to _pjl_io.py. usb_query imports the three names test_usb_query.py patches back, which is safe here only because all three still have real call sites in the code that stayed; an import-only name would be stripped by ruff's F401 sweep and break the patch. os and time also stay: they are patched on usb_query as well as on _pjl_io. test_usb_query.py needs no edit. Pass count is 402 before and after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
181 lines
6.0 KiB
Python
181 lines
6.0 KiB
Python
"""USB printer discovery and PJL query functions.
|
|
|
|
The non-blocking PJL transport lives in _pjl_io.py. Its three entry points are
|
|
imported back here because tests/test_usb_query.py patches them on this module.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import errno
|
|
import importlib
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import time
|
|
from typing import TYPE_CHECKING
|
|
|
|
from python_pkg.brother_printer._pjl_io import (
|
|
_drain_buffer,
|
|
_write_with_deadline,
|
|
pjl_query,
|
|
)
|
|
from python_pkg.brother_printer._query import (
|
|
printer_info_from_cups,
|
|
run_command_text,
|
|
)
|
|
from python_pkg.brother_printer.data_classes import USBResult
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ── USB printer discovery ────────────────────────────────────────────
|
|
|
|
|
|
def find_brother_usb() -> str:
|
|
"""Look for any Brother printer on USB via lsusb. Returns the info line."""
|
|
if not shutil.which("lsusb"):
|
|
return ""
|
|
for line in run_command_text(["/usr/bin/lsusb"]).splitlines():
|
|
if "04f9:" in line.lower():
|
|
return line.split(": ", 1)[1] if ": " in line else line
|
|
return ""
|
|
|
|
|
|
def find_usb_printer_dev() -> str | None:
|
|
"""Find /dev/usb/lp* device for the Brother printer."""
|
|
devices = sorted(Path("/dev/usb").glob("lp*"))
|
|
return str(devices[0]) if devices else None
|
|
|
|
|
|
def _parse_status(resp: str, result: USBResult) -> bool:
|
|
"""Parse STATUS response into result. Returns True if code was found."""
|
|
found = False
|
|
for raw_line in resp.splitlines():
|
|
stripped = raw_line.strip()
|
|
if stripped.startswith("CODE="):
|
|
result.status_code = stripped.split("=", 1)[1]
|
|
found = True
|
|
elif stripped.startswith("DISPLAY="):
|
|
result.display = stripped.split("=", 1)[1].strip().strip('"').strip()
|
|
elif stripped.startswith("ONLINE="):
|
|
result.online = stripped.split("=", 1)[1]
|
|
return found
|
|
|
|
|
|
def _parse_pagecount(resp: str, result: USBResult) -> bool:
|
|
"""Parse PAGECOUNT response into result. Returns True if a count was found."""
|
|
for raw_line in resp.splitlines():
|
|
stripped = raw_line.strip()
|
|
if stripped.startswith("PAGECOUNT="):
|
|
value = stripped.split("=", 1)[1].strip()
|
|
if value.isdigit():
|
|
result.page_count = value
|
|
return True
|
|
return False
|
|
|
|
|
|
def _parse_variables(resp: str, result: USBResult) -> bool:
|
|
"""Parse VARIABLES response into result. Returns True if data found."""
|
|
found = False
|
|
for raw_line in resp.splitlines():
|
|
stripped = raw_line.strip()
|
|
if stripped.startswith("ECONOMODE="):
|
|
result.economode = stripped.split("=", 1)[1].split()[0]
|
|
found = True
|
|
return found
|
|
|
|
|
|
def _retry_pjl_query(
|
|
fd: int,
|
|
cmd: str,
|
|
parser: Callable[[str, USBResult], bool],
|
|
result: USBResult,
|
|
max_retries: int,
|
|
) -> None:
|
|
"""Send a PJL query with retries, draining between attempts."""
|
|
for attempt in range(max_retries + 1):
|
|
resp = pjl_query(fd, cmd)
|
|
if parser(resp, result):
|
|
break
|
|
if attempt < max_retries:
|
|
_drain_buffer(fd)
|
|
time.sleep(0.5)
|
|
|
|
|
|
def _run_pjl_queries(fd: int, result: USBResult, max_retries: int) -> None:
|
|
"""Execute PJL query sequence on an open file descriptor."""
|
|
_drain_buffer(fd)
|
|
|
|
# Universal Exit Language, to drop the printer out of any language mode it
|
|
# is in and have it listen for PJL. Deadline-bounded like every other write.
|
|
_write_with_deadline(fd, b"\x1b%-12345X@PJL\r\n\x1b%-12345X", time.time() + 5.0)
|
|
time.sleep(0.5)
|
|
_drain_buffer(fd)
|
|
|
|
_retry_pjl_query(fd, "@PJL INFO STATUS", _parse_status, result, max_retries)
|
|
_drain_buffer(fd)
|
|
time.sleep(0.5)
|
|
_retry_pjl_query(fd, "@PJL INFO PAGECOUNT", _parse_pagecount, result, max_retries)
|
|
_drain_buffer(fd)
|
|
time.sleep(0.5)
|
|
_retry_pjl_query(fd, "@PJL INFO VARIABLES", _parse_variables, result, max_retries)
|
|
|
|
|
|
def _init_usb_result(dev_path: str) -> USBResult:
|
|
"""Create a USBResult with device info from CUPS."""
|
|
cups_info = printer_info_from_cups()
|
|
return USBResult(
|
|
device=dev_path,
|
|
product=cups_info.get("product") or "Brother Laser Printer",
|
|
serial=cups_info.get("serial", ""),
|
|
)
|
|
|
|
|
|
def query_usb_pjl(max_retries: int = 2) -> USBResult:
|
|
"""Query a Brother printer via PJL over /dev/usb/lp*."""
|
|
dev_path = find_usb_printer_dev()
|
|
if not dev_path:
|
|
cups_fallback = importlib.import_module(
|
|
"python_pkg.brother_printer._cups_fallback",
|
|
)
|
|
return cups_fallback.query_usb_via_cups()
|
|
|
|
result = _init_usb_result(dev_path)
|
|
if not os.access(dev_path, os.R_OK | os.W_OK):
|
|
result.error = f"Permission denied: {dev_path}. Run with sudo."
|
|
return result
|
|
|
|
fd: int | None = None
|
|
try:
|
|
# O_NONBLOCK or this open() hangs forever on an unwell printer.
|
|
fd = os.open(dev_path, os.O_RDWR | os.O_NONBLOCK)
|
|
_run_pjl_queries(fd, result, max_retries)
|
|
except OSError as e:
|
|
result.error = _describe_open_error(dev_path, e)
|
|
finally:
|
|
if fd is not None:
|
|
os.close(fd)
|
|
if not result.status_code and not result.error:
|
|
result.error = (
|
|
f"The printer did not answer PJL on {dev_path}. It is usually busy"
|
|
" with a job, or wedged - power-cycle it if this persists."
|
|
)
|
|
return result
|
|
|
|
|
|
def _describe_open_error(dev_path: str, exc: OSError) -> str:
|
|
"""Turn an errno from opening the printer into something actionable."""
|
|
if exc.errno == errno.EBUSY:
|
|
return (
|
|
f"{dev_path} is busy: another process (usually the CUPS backend"
|
|
" mid-job) holds the printer. Wait for the job to finish."
|
|
)
|
|
if exc.errno == errno.EACCES:
|
|
return f"Permission denied: {dev_path}. Run with sudo."
|
|
return f"Could not read {dev_path}: {exc}"
|