testsAndMisc/python_pkg/brother_printer/display.py
Krzysztof kuhy Rudnicki a12e696494
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
fix(brother-printer): report the truth, not invented status codes
./run.sh failed outright with ModuleNotFoundError, and once running it
reported a sleeping printer as a critical paper jam. Both are fixed, plus
the defects found while verifying them against the real HL-1110.

Entry point: run.sh ran the script by path, so the absolute
python_pkg.* imports could not resolve. Resolve the repo root and pass
PYTHONPATH as a sudo argument, since sudo's env_reset strips an exported
one. __main__.py had the wrong import prefix and ran main() on import.

Status codes: the table claimed provenance from a "Brother PJL Technical
Reference" that does not appear to exist, and 23 of its 25 entries were
wrong. Rebuilt from the HP PJL Technical Reference Manual, Appendix D,
and verified against six states induced on real hardware (10001 READY,
10003 WARMING UP, 10023 PRINTING, 40000 SLEEP, 40021 TOP COVER OPEN,
41213 NO PAPER) - all six matched the reference, none matched the table.
40000 is SLEEP MODE, which the reference explicitly flags "not an error";
the real jam code 40022 was absent entirely. The inverse also bit: 10006
is TONER LOW, previously shown as a benign "Processing". Codes in no
reference are dropped and now report "Unknown status - check printer
display" rather than a confident wrong answer, and states we infer
ourselves no longer borrow PJL numbers to describe themselves.

Page counts: estimates came from the CUPS page log, which only sees jobs
CUPS logged and counts pages the printer never printed (it logged
total=50 for a job that printed 3). Take them from @PJL INFO PAGECOUNT
instead, migrating stored baselines onto the printer's scale once so the
reported percentages do not jump.

Reading status must not cost anything: a blocking open() hung forever
whenever the printer was unwell - precisely when a status report is
wanted - and the port-status probe stopped CUPS and reset the USB device,
destroying the in-flight job and then misreporting the damage as a
printer error. The fd is now non-blocking with deadline-bounded writes,
and the probe reads the same byte via the free usblp ioctl, skips
entirely while a job prints, and re-attaches the kernel driver.

Dropped pages: the HL-1110 silently discards pages whose 600dpi raster
exceeds its ~1MB memory, stays READY, and CUPS still calls the job
successful - so nothing anywhere reports the failure. Compare CUPS's
claim against the printer's counter between jobs and name the workaround
(lp -o Resolution=300dpi).

402 tests, 100% branch coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RG3eEn8rwZbcXaJrKJxdv8
2026-07-17 14:49:23 +02:00

428 lines
14 KiB
Python

"""Display and formatting functions for Brother printer status reports."""
from __future__ import annotations
import sys
from python_pkg.brother_printer.constants import (
BOLD,
CYAN,
DIM,
DRUM_RATED_PAGES,
GREEN,
PROGRESS_BAR_WIDTH,
RED,
RESET,
SNMP_LEVEL_LOW,
SNMP_LEVEL_OK,
SUPPLY_LOW_PCT,
SUPPLY_WARN_PCT,
TONER_RATED_PAGES,
YELLOW,
_out,
get_status_info,
)
from python_pkg.brother_printer.cups_queue import (
display_cups_queue_status,
get_cups_queue_status,
)
from python_pkg.brother_printer.cups_service import (
check_page_delivery,
estimate_consumable_life,
)
from python_pkg.brother_printer.data_classes import (
NetworkResult,
SupplyStatus,
USBResult,
)
# ── Shared display helpers ───────────────────────────────────────────
def _display_report_header() -> None:
"""Print the report banner box."""
_out()
_out(f"{BOLD}╔══════════════════════════════════════════════════╗{RESET}")
_out(f"{BOLD}║ Brother Laser Printer Status Report ║{RESET}")
_out(f"{BOLD}╚══════════════════════════════════════════════════╝{RESET}")
_out()
def _display_page_delivery_warning(printer_total: int, *, queue_idle: bool) -> None:
"""Warn when CUPS claims more pages than the printer actually counted.
Args:
printer_total: Lifetime count from the printer's own counter.
queue_idle: False while a job is queued or printing, when the two
counters legitimately disagree.
"""
check = check_page_delivery(printer_total, queue_idle=queue_idle)
if not check.suspected:
return
_out(f"{BOLD}── Dropped Pages ──{RESET}")
_out()
_out(
f" {RED}{BOLD}⚠ {check.dropped} pages did not print.{RESET}"
f" {RED}CUPS sent {check.cups_pages} pages since the last check;"
f" the printer's own counter advanced by"
f" {check.printer_pages}.{RESET}"
)
_out()
_out(
f" {DIM}The printer discards pages whose 600 dpi raster does not fit"
f" its memory, stays READY, and reports no error - and CUPS still calls"
f" the job successful. Reprint at a lower resolution:{RESET}"
)
_out(f" {DIM} lp -o Resolution=300dpi <file>{RESET}")
_out()
def _display_page_count_estimate(printer_total: int = 0) -> None:
"""Show estimated consumable life based on the printer's page count.
Args:
printer_total: Lifetime count from the printer's own counter, or zero
when it could not be read and the CUPS page log has to stand in.
"""
estimate = estimate_consumable_life(printer_total)
if estimate.total_pages <= 0:
return
_out(f"{BOLD}── Page Count Estimate ──{RESET}")
_out()
_out(
f" {BOLD}Total pages printed:{RESET} {estimate.total_pages}"
f" (toner: {estimate.toner_pages} since replacement,"
f" drum: {estimate.drum_pages} since replacement)"
)
if estimate.approximate:
_out(
f" {YELLOW}Approximate: counted from the CUPS log, not the"
f" printer's own counter, so it misses pages CUPS never saw"
f" and reads high.{RESET}"
)
_out()
# Toner bar
toner_pct = estimate.toner_pct_remaining
toner_filled = toner_pct * PROGRESS_BAR_WIDTH // 100
toner_empty = PROGRESS_BAR_WIDTH - toner_filled
toner_bar = f"[{'█' * toner_filled}{'░' * toner_empty}]"
if estimate.toner_exhausted:
toner_color = RED
toner_note = " ← REPLACE NOW"
elif estimate.toner_low:
toner_color = YELLOW
toner_note = " ← order soon"
else:
toner_color = GREEN
toner_note = ""
_out(
f" {BOLD}Toner:{RESET} {toner_color}{toner_bar} ~{toner_pct}%"
f"{toner_note}{RESET}"
)
# Drum bar
drum_pct = estimate.drum_pct_remaining
drum_filled = drum_pct * PROGRESS_BAR_WIDTH // 100
drum_empty = PROGRESS_BAR_WIDTH - drum_filled
drum_bar = f"[{'█' * drum_filled}{'░' * drum_empty}]"
if estimate.drum_near_end:
drum_color = YELLOW
drum_note = " ← nearing end"
else:
drum_color = GREEN
drum_note = ""
_out(f" {BOLD}Drum:{RESET} {drum_color}{drum_bar} ~{drum_pct}%{drum_note}{RESET}")
_out(
f" {DIM}Based on pages since last replacement"
f" vs rated capacity (toner ~{TONER_RATED_PAGES},"
f" drum ~{DRUM_RATED_PAGES}).{RESET}"
)
_out(f" {DIM}Reset after replacing: --reset-toner or --reset-drum{RESET}")
if estimate.toner_exhausted:
_out()
_out(
f" {RED}{BOLD}⚠ Toner is likely exhausted."
f" This is probably why the orange light is flashing.{RESET}"
)
_out()
def _display_consumables_reference() -> None:
"""Print compatible consumables reference."""
_out(f"{BOLD}── Compatible Consumables ──{RESET}")
_out()
_out(f" {BOLD}Toner:{RESET} TN-1050 / TN-1030 (or compatible third-party)")
_out(f" {BOLD}Drum:{RESET} DR-1050 / DR-1030 (or compatible third-party)")
_out(f" {DIM} Toner rated ~1000 pages; Drum rated ~10000 pages.{RESET}")
_out()
# ── USB display helpers ──────────────────────────────────────────────
def _display_usb_device_info(result: USBResult) -> None:
"""Print device info block for USB results."""
_out(f"{BOLD}Printer:{RESET} {result.product or 'Unknown'}")
_out(f"{BOLD}Connection:{RESET} USB")
if result.serial:
_out(f"{BOLD}Serial:{RESET} {result.serial}")
if result.online == "TRUE":
_out(f"{BOLD}Online:{RESET} {GREEN}Yes{RESET}")
elif result.online == "FALSE":
_out(f"{BOLD}Online:{RESET} {YELLOW}No (needs attention){RESET}")
_out()
if result.economode:
if result.economode == "ON":
_out(
f"{BOLD}Toner Save:{RESET} {GREEN}ON{RESET}"
" (extends toner life, lighter prints)"
)
else:
_out(f"{BOLD}Toner Save:{RESET} OFF")
_SEVERITY_ICONS: dict[str, str] = {
"ok": "✓",
"info": "i",
"warn": "⚡",
"critical": "⚠",
}
_SEVERITY_COLORS: dict[str, str] = {
"ok": GREEN,
"info": CYAN,
"warn": YELLOW,
"critical": RED,
}
_SEVERITY_SUMMARIES: dict[str, str] = {
"ok": f"{GREEN}{BOLD}✓ Printer is healthy. No replacements needed.{RESET}",
"info": (
f"{CYAN}{BOLD}i Printer is busy/processing. No replacements needed.{RESET}"
),
"warn": (
f"{YELLOW}{BOLD}⚡ WARNING: Maintenance will be needed"
f" soon.{RESET}\n{YELLOW} Order replacement parts"
f" now to avoid interruption.{RESET}"
),
"critical": (
f"{RED}{BOLD}⚠ ACTION REQUIRED: Replacement or fix needed now!{RESET}"
),
}
def _format_status_detail(
severity: str, short_text: str, action: str, result: USBResult
) -> None:
"""Print severity icon, display text, and action."""
color = _SEVERITY_COLORS.get(severity, GREEN)
icon = _SEVERITY_ICONS.get(severity, "✓")
_out(f" {color}{BOLD}{icon} {short_text}{RESET}")
if result.display and result.display != short_text:
_out(f" {DIM}Display: {result.display}{RESET}")
_out(f" {DIM}Status code: {result.status_code}{RESET}")
if action:
_out()
_out(f" {color}{BOLD}Action:{RESET} {color}{action}{RESET}")
_out()
_out(_SEVERITY_SUMMARIES.get(severity, ""))
def _display_pjl_status(result: USBResult) -> None:
"""Display PJL status code interpretation."""
_out()
_out(f"{BOLD}── Printer Status ──{RESET}")
_out()
if not result.status_code:
_out(f" {YELLOW}Could not read status from printer.{RESET}")
if result.display:
_out(f" Display message: {BOLD}{result.display}{RESET}")
return
severity, short_text, action = get_status_info(result.status_code)
_format_status_detail(severity, short_text, action, result)
def _display_cups_fallback_note(result: USBResult) -> None:
"""Show a note when running in CUPS fallback mode."""
_out()
if result.port_status is not None:
_out(
f" {DIM}Note: Hardware status obtained via USB port query."
f" Toner/drum percentages not available.{RESET}"
)
else:
_out(
f" {DIM}Note: pyusb not available; status obtained via"
f" CUPS only. Detailed toner/drum levels are not"
f" available in this mode.{RESET}"
)
# ── USB results display ─────────────────────────────────────────────
def display_usb_results(result: USBResult) -> None:
"""Print a formatted report for USB PJL query results."""
if result.error:
_out(f"{RED}Error: {result.error}{RESET}")
sys.exit(1)
_display_report_header()
_display_usb_device_info(result)
_display_pjl_status(result)
if result.device == "cups":
_display_cups_fallback_note(result)
_out()
printer_total = int(result.page_count) if result.page_count.isdigit() else 0
queue = get_cups_queue_status()
_display_page_delivery_warning(printer_total, queue_idle=not queue.jobs)
_display_page_count_estimate(printer_total)
_display_consumables_reference()
display_cups_queue_status(queue)
# ── Network supply level helpers ─────────────────────────────────────
def _classify_percentage_level(desc: str, pct: int) -> tuple[int, str, str, str, bool]:
"""Classify a supply by its calculated percentage."""
if pct <= SUPPLY_LOW_PCT:
return pct, f"{pct}%", RED, f"{desc} at {pct}%.", True
if pct <= SUPPLY_WARN_PCT:
return pct, f"{pct}%", YELLOW, f"{desc} at {pct}% -- order soon.", False
return pct, f"{pct}%", GREEN, "", False
def _classify_supply_level(
desc: str, max_val: int, level: int
) -> tuple[int, str, str, str, bool]:
"""Classify a supply level. Returns (pct, status, color, warning, replace)."""
if level == SNMP_LEVEL_OK:
return -1, "OK", GREEN, "", False
if level == SNMP_LEVEL_LOW:
return -1, "LOW", RED, f"{desc} is LOW.", True
if level == 0:
return 0, "EMPTY", RED, f"{desc} is EMPTY -- replace now!", True
if max_val > 0:
pct = min(level * 100 // max_val, 100)
return _classify_percentage_level(desc, pct)
return -1, "", GREEN, "", False
def _format_supply_bar(pct: int) -> str:
"""Build a progress bar string for a supply percentage."""
if pct < 0:
return ""
filled = pct * PROGRESS_BAR_WIDTH // 100
empty = PROGRESS_BAR_WIDTH - filled
return f"[{'█' * filled}{'░' * empty}]"
def _process_supply_item(desc: str, max_val: int, level: int) -> SupplyStatus:
"""Process a single supply item into display info."""
pct, status_text, color, warning, needs_replacement = _classify_supply_level(
desc, max_val, level
)
bar_text = _format_supply_bar(pct)
return SupplyStatus(color, bar_text, status_text, warning, needs_replacement)
def _display_supply_warnings(*, needs_replacement: bool, warnings: list[str]) -> None:
"""Display supply level warnings summary."""
_out()
if needs_replacement:
_out(f"{RED}{BOLD}⚠ ACTION NEEDED:{RESET}")
for w in warnings:
_out(f" {RED}• {w}{RESET}")
elif warnings:
_out(f"{YELLOW}{BOLD}⚡ HEADS UP:{RESET}")
for w in warnings:
_out(f" {YELLOW}• {w}{RESET}")
else:
_out(f"{GREEN}{BOLD}✓ All consumables are at healthy levels.{RESET}")
def _parse_supply_value(values: list[str], index: int) -> int:
"""Safely parse an integer from a supply value list."""
try:
return int(values[index])
except (IndexError, ValueError):
return 0
def _collect_supply_items(
result: NetworkResult,
) -> tuple[list[SupplyStatus], list[str]]:
"""Parse and collect supply items with their descriptions."""
items: list[SupplyStatus] = []
descs: list[str] = []
for i, desc in enumerate(result.supplies.descriptions):
max_val = _parse_supply_value(result.supplies.max_values, i)
level = _parse_supply_value(result.supplies.levels, i)
items.append(_process_supply_item(desc, max_val, level))
descs.append(desc)
return items, descs
def _display_supply_levels(result: NetworkResult) -> None:
"""Display consumable supply levels section."""
_out()
_out(f"{BOLD}── Consumable Levels ──{RESET}")
_out()
needs_replacement = False
warnings: list[str] = []
items, descs = _collect_supply_items(result)
for desc, item in zip(descs, items, strict=True):
_out(
f" {BOLD}{desc:<25}{RESET}"
f" {item.color}{item.bar_text} {item.status_text}{RESET}"
)
if item.needs_replacement:
needs_replacement = True
if item.warning:
warnings.append(item.warning)
_display_supply_warnings(needs_replacement=needs_replacement, warnings=warnings)
def _display_network_device_info(result: NetworkResult) -> None:
"""Display device info section for network results."""
_out(f"{BOLD}Printer:{RESET} {result.product or 'Unknown'}")
_out(f"{BOLD}Connection:{RESET} Network ({result.ip})")
if result.serial:
_out(f"{BOLD}Serial:{RESET} {result.serial}")
if result.display:
_out(f"{BOLD}Display:{RESET} {result.display}")
if result.page_count and result.page_count.isdigit():
_out(f"{BOLD}Pages:{RESET} {result.page_count} total")
# ── Network results display ──────────────────────────────────────────
def display_network_results(result: NetworkResult) -> None:
"""Print a formatted report for SNMP network query results."""
if result.error:
_out(f"{RED}Error: {result.error}{RESET}")
sys.exit(1)
_display_report_header()
_display_network_device_info(result)
_display_supply_levels(result)
_out()
_out(
f"{CYAN}Tip: Visit http://{result.ip} for the full web management"
f" interface.{RESET}"
)
_out()