mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 19:47:12 +01:00
./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
428 lines
14 KiB
Python
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()
|