#!/usr/bin/env python3 # /// script # dependencies = [ # "pyipp>=0.16.0", # "requests>=2.31.0", # "pypdf>=4.0.0", # "reportlab>=4.0.0", # ] # /// """ Brother HL-L8240CDW Network Printer Controller & IPP Driver for OpenClaw. Supports direct PDF printing, hardware N-Up, duplex, color modes, and live Telegram status updates via Bot API. """ import os import sys import time import json import argparse import asyncio from pathlib import Path from typing import Optional, Dict, Any import requests from pypdf import PdfReader from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer from reportlab.lib.units import cm import pyipp from pyipp import IPP from pyipp.enums import IppOperation from pyipp.tags import IppTag import pyipp.serializer # Register extra IPP attribute tags for Brother HL-L8240CDW pyipp.serializer.ATTRIBUTE_TAG_MAP["print-color-mode"] = IppTag.KEYWORD pyipp.serializer.ATTRIBUTE_TAG_MAP["page-ranges"] = IppTag.RANGE DEFAULT_PRINTER_HOST = os.environ.get("BROTHER_PRINTER_HOST", "192.168.10.91") DEFAULT_PRINTER_PORT = int(os.environ.get("BROTHER_PRINTER_PORT", "631")) DEFAULT_PRINTER_URI = f"ipp://{DEFAULT_PRINTER_HOST}:{DEFAULT_PRINTER_PORT}/ipp/print" TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN") # --------------------------------------------------------------------------- # Telegram Helper # --------------------------------------------------------------------------- class TelegramNotifier: def __init__(self, token: Optional[str] = None, chat_id: Optional[str] = None, thread_id: Optional[int] = None, message_id: Optional[int] = None): self.token = token or TELEGRAM_BOT_TOKEN self.chat_id = chat_id self.thread_id = int(thread_id) if thread_id else None self.message_id = int(message_id) if message_id else None def can_notify(self) -> bool: return bool(self.token and self.chat_id) def send_initial(self, text: str) -> Optional[int]: if not self.can_notify(): return None url = f"https://api.telegram.org/bot{self.token}/sendMessage" payload = { "chat_id": self.chat_id, "text": text, "parse_mode": "Markdown", } if self.thread_id: payload["message_thread_id"] = self.thread_id try: resp = requests.post(url, json=payload, timeout=8) if resp.ok: data = resp.json() self.message_id = data.get("result", {}).get("message_id") return self.message_id except Exception as e: sys.stderr.write(f"Telegram send error: {e}\n") return None def update(self, text: str): if not self.can_notify() or not self.message_id: return url = f"https://api.telegram.org/bot{self.token}/editMessageText" payload = { "chat_id": self.chat_id, "message_id": self.message_id, "text": text, "parse_mode": "Markdown", } try: requests.post(url, json=payload, timeout=8) except Exception as e: sys.stderr.write(f"Telegram edit error: {e}\n") # --------------------------------------------------------------------------- # Text to PDF Converter # --------------------------------------------------------------------------- def convert_text_to_pdf(text: str, output_path: str, title: str = "Dokument") -> str: """Convert plain text or simple markdown into a clean A4 PDF.""" doc = SimpleDocTemplate( output_path, pagesize=A4, rightMargin=2 * cm, leftMargin=2 * cm, topMargin=2 * cm, bottomMargin=2 * cm, ) styles = getSampleStyleSheet() normal_style = styles["Normal"] normal_style.fontSize = 11 normal_style.leading = 14 heading_style = styles["Heading1"] heading_style.fontSize = 16 heading_style.leading = 20 story = [ Paragraph(title, heading_style), Spacer(1, 0.5 * cm), ] for paragraph in text.split("\n\n"): clean_p = paragraph.replace("\n", "
") story.append(Paragraph(clean_p, normal_style)) story.append(Spacer(1, 0.3 * cm)) doc.build(story) return output_path # --------------------------------------------------------------------------- # Printer Service # --------------------------------------------------------------------------- class BrotherPrinter: def __init__(self, host: str = DEFAULT_PRINTER_HOST, port: int = DEFAULT_PRINTER_PORT): self.host = host self.port = port self.uri = f"ipp://{self.host}:{self.port}/ipp/print" async def get_status(self) -> Dict[str, Any]: """Fetch status, markers, and printer details via IPP.""" async with IPP(self.host, port=self.port) as ipp: printer = await ipp.printer() markers = {} for marker in printer.markers: markers[marker.name] = { "level": marker.level, "type": marker.marker_type, "color": marker.color, } return { "name": printer.info.name, "model": printer.info.model, "state": printer.state.printer_state, "reasons": printer.state.reasons, "markers": markers, "uri": self.uri, } async def validate_job( self, color_mode: str = "monochrome", nup: int = 1, duplex: str = "two-sided-long-edge", copies: int = 1, ) -> bool: """Dry-run validation of job parameters without printing.""" async with IPP(self.host, port=self.port) as ipp: msg = { "version": (2, 0), "operation": IppOperation.VALIDATE_JOB, "operation-attributes-tag": { "printer-uri": self.uri, "requesting-user-name": "openclaw", "document-format": "application/pdf", }, "job-attributes-tag": { "sides": duplex, "number-up": nup, "print-color-mode": color_mode, "copies": copies, }, } res = await ipp.execute(IppOperation.VALIDATE_JOB, msg) return res.get("status-code") == 0 async def send_print_job( self, pdf_bytes: bytes, job_name: str = "OpenClaw Print", color_mode: str = "monochrome", nup: int = 1, duplex: str = "two-sided-long-edge", copies: int = 1, ) -> Dict[str, Any]: """Send a Print-Job request directly with raw PDF data.""" async with IPP(self.host, port=self.port) as ipp: msg = { "version": (2, 0), "operation": IppOperation.PRINT_JOB, "operation-attributes-tag": { "printer-uri": self.uri, "requesting-user-name": "openclaw", "job-name": job_name, "document-format": "application/pdf", }, "job-attributes-tag": { "sides": duplex, "number-up": nup, "print-color-mode": color_mode, "copies": copies, }, "data": pdf_bytes, } res = await ipp.execute(IppOperation.PRINT_JOB, msg) return res async def get_job_attributes(self, job_id: int) -> Dict[str, Any]: """Poll job status using GET_JOB_ATTRIBUTES.""" async with IPP(self.host, port=self.port) as ipp: msg = { "version": (2, 0), "operation": IppOperation.GET_JOB_ATTRIBUTES, "operation-attributes-tag": { "printer-uri": self.uri, "job-id": job_id, "requesting-user-name": "openclaw", }, } try: res = await ipp.execute(IppOperation.GET_JOB_ATTRIBUTES, msg) jobs = res.get("jobs", []) if jobs: return jobs[0] except Exception as e: sys.stderr.write(f"Job query warning: {e}\n") return {} # --------------------------------------------------------------------------- # CLI Command Handlers # --------------------------------------------------------------------------- def handle_status(args): printer = BrotherPrinter(host=args.host, port=args.port) data = asyncio.run(printer.get_status()) if args.json: print(json.dumps(data, indent=2)) return print("🖨️ Brother HL-L8240CDW Status") print("=" * 35) print(f"Modell: {data['model']}") print(f"Status: {data['state']}") print(f"URI: {data['uri']}") print("\n📊 Tonerstände:") for name, m in data["markers"].items(): color_label = {"BK": "Schwarz", "C": "Cyan", "M": "Magenta", "Y": "Gelb"}.get(name, name) bar = "█" * (m["level"] // 10) + "░" * (10 - (m["level"] // 10)) print(f" [{name:2}] {color_label:8}: {bar} {m['level']}%") def handle_validate(args): printer = BrotherPrinter(host=args.host, port=args.port) ok = asyncio.run( printer.validate_job( color_mode=args.color, nup=args.nup, duplex=args.duplex, copies=args.copies, ) ) if ok: print("✅ Konfiguration ist gültig und wird vom Drucker unterstützt.") else: print("❌ Konfiguration wurde vom Drucker abgewiesen.") sys.exit(1) def handle_print(args): notifier = TelegramNotifier(chat_id=args.chat_id, thread_id=args.thread_id, message_id=args.message_id) # 1. Resolve source document doc_title = args.title or "Dokument" pdf_path = None temp_files = [] if args.file: p = Path(args.file) if not p.exists(): err = f"❌ Datei nicht gefunden: {args.file}" print(err) notifier.update(err) sys.exit(1) pdf_path = str(p) doc_title = args.title or p.name elif args.url: notifier.update(f"⏳ [1/4] Lade Dokument herunter: `{args.url[:40]}...`") resp = requests.get(args.url, timeout=30) tmp = f"/tmp/print_{int(time.time())}.pdf" with open(tmp, "wb") as f: f.write(resp.content) temp_files.append(tmp) pdf_path = tmp doc_title = args.title or Path(args.url).name elif args.text: notifier.update("⏳ [1/4] Formatiere Text als Druck-PDF...") tmp = f"/tmp/print_{int(time.time())}.pdf" convert_text_to_pdf(args.text, tmp, title=doc_title) temp_files.append(tmp) pdf_path = tmp else: err = "❌ Kein Dokument angegeben (--file, --url oder --text erforderlich)." print(err) notifier.update(err) sys.exit(1) # 2. Inspect PDF properties try: reader = PdfReader(pdf_path) total_pages = len(reader.pages) except Exception as e: total_pages = 1 # Safety guardrail: check max pages if total_pages > args.max_pages and not args.force: err = f"⚠️ *Druck abgebrochen*: Dokument hat {total_pages} Seiten (Limit: {args.max_pages}). Verwende `--force` zum Überschreiben." print(err) notifier.update(err) sys.exit(1) # Calculate estimated sheets sheets = (total_pages + (args.nup - 1)) // args.nup if args.duplex != "one-sided": sheets = (sheets + 1) // 2 color_desc = "Farbe 🎨" if args.color == "color" else "Schwarz-Weiß 🖤" duplex_desc = "Duplex (beidseitig) 🔄" if args.duplex != "one-sided" else "Simplex (einseitig) 📄" nup_desc = f"{args.nup} Seite(n) pro Blatt" # Initial status notification status_header = ( f"🖨️ *Druckauftrag: {doc_title}*\n" f"• *Umfang*: {total_pages} Seite(n) (~{sheets} Blatt Papier)\n" f"• *Modus*: {color_desc} | {nup_desc} | {duplex_desc}\n" ) if not notifier.message_id and notifier.can_notify(): notifier.send_initial(f"{status_header}• *Status*: ⏳ [1/3] Bereite Übertragung vor...") else: notifier.update(f"{status_header}• *Status*: ⏳ [1/3] Bereite Übertragung vor...") # 3. Read PDF bytes with open(pdf_path, "rb") as f: pdf_data = f.read() # 4. Connect to printer and send job notifier.update(f"{status_header}• *Status*: 📡 [2/3] Sende an Brother HL-L8240CDW...") printer = BrotherPrinter(host=args.host, port=args.port) try: res = asyncio.run( printer.send_print_job( pdf_bytes=pdf_data, job_name=doc_title, color_mode=args.color, nup=args.nup, duplex=args.duplex, copies=args.copies, ) ) except Exception as e: err_msg = f"{status_header}• *Status*: ❌ Verbindungsfehler zum Drucker: `{e}`" print(err_msg) notifier.update(err_msg) sys.exit(1) # Check IPP status code status_code = res.get("status-code", -1) if status_code != 0: err_msg = f"{status_header}• *Status*: ❌ Drucker meldet Fehler-Code `{status_code}`." print(err_msg) notifier.update(err_msg) sys.exit(1) # Extract Job ID jobs = res.get("jobs", []) job_id = jobs[0].get("job-id", "unbekannt") if jobs else "unbekannt" print(f"Print job queued. Job ID: {job_id}") notifier.update(f"{status_header}• *Status*: 🖨️ [3/3] Auftrag übertragen (Job #{job_id}). Druckvorgang läuft...") # 5. Monitor Job Progress (up to 30 seconds) if isinstance(job_id, int): for _ in range(15): time.sleep(2) try: attr = asyncio.run(printer.get_job_attributes(job_id)) state = attr.get("job-state") # 9 = completed, 7 = canceled, 8 = aborted if state == 9: break elif state in (7, 8): notifier.update(f"{status_header}• *Status*: ⚠️ Auftrag #{job_id} wurde am Drucker abgebrochen.") return except Exception: pass # Final success update final_msg = ( f"{status_header}" f"• *Status*: ✅ **Erfolgreich gedruckt!**\n" f"• *Job-ID*: `#{job_id}`\n" f"• *Ausgabe*: Liegt im Ausgabefach bereit." ) print("Druck erfolgreich abgeschlossen.") notifier.update(final_msg) # Cleanup temp files for t in temp_files: try: os.remove(t) except Exception: pass # --------------------------------------------------------------------------- # Main Entry Point # --------------------------------------------------------------------------- def main(): parser = argparse.ArgumentParser(description="Brother HL-L8240CDW IPP Print Utility for OpenClaw") parser.add_argument("--host", default=DEFAULT_PRINTER_HOST, help="Printer IP or Hostname") parser.add_argument("--port", type=int, default=DEFAULT_PRINTER_PORT, help="IPP Port (default 631)") subparsers = parser.add_subparsers(dest="command", required=True) # Subcommand: status status_parser = subparsers.add_parser("status", help="Get printer status and toner levels") status_parser.add_argument("--json", action="store_true", help="Output as JSON") status_parser.set_defaults(func=handle_status) # Subcommand: validate val_parser = subparsers.add_parser("validate", help="Dry-run validate print parameters") val_parser.add_argument("--color", choices=["monochrome", "color"], default="monochrome") val_parser.add_argument("--nup", type=int, choices=[1, 2, 4], default=1) val_parser.add_argument("--duplex", choices=["two-sided-long-edge", "two-sided-short-edge", "one-sided"], default="two-sided-long-edge") val_parser.add_argument("--copies", type=int, default=1) val_parser.set_defaults(func=handle_validate) # Subcommand: print print_parser = subparsers.add_parser("print", help="Print a document") print_parser.add_argument("--file", help="Path to PDF file") print_parser.add_argument("--url", help="URL of PDF document") print_parser.add_argument("--text", help="Raw text to convert and print") print_parser.add_argument("--title", help="Job or document title") print_parser.add_argument("--color", choices=["monochrome", "color"], default="monochrome", help="Color mode (default: monochrome)") print_parser.add_argument("--nup", type=int, choices=[1, 2, 4], default=1, help="Pages per sheet (1, 2 or 4)") print_parser.add_argument("--duplex", choices=["two-sided-long-edge", "two-sided-short-edge", "one-sided"], default="two-sided-long-edge", help="Duplex mode (default: two-sided-long-edge)") print_parser.add_argument("--copies", type=int, default=1, help="Number of copies") print_parser.add_argument("--max-pages", type=int, default=30, help="Safety page limit (default 30)") print_parser.add_argument("--force", action="store_true", help="Bypass max pages guardrail") print_parser.add_argument("--chat-id", help="Telegram Chat ID for live updates") print_parser.add_argument("--thread-id", type=int, help="Telegram Thread / Topic ID (for forum supergroups)") print_parser.add_argument("--message-id", help="Telegram Message ID to edit (optional, created if omitted)") print_parser.set_defaults(func=handle_print) args = parser.parse_args() args.func(args) if __name__ == "__main__": main()