feat(skills): add brother-printer skill with IPP duplex, color, N-up and Telegram live status

This commit is contained in:
Moritz Graf
2026-09-17 08:08:31 +02:00
parent bb31002e19
commit b104b2f6a2
2 changed files with 582 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
---
name: brother-printer
description: Print PDF documents, text, and files directly to the Brother HL-L8240CDW duplex color laser printer at Grabengasse 7 (192.168.10.91). Handles interactive Telegram print options (Color/B&W, Pages per Sheet / N-Up, Duplex/Simplex) and live-updating Telegram status messages.
---
# 🖨️ Brother HL-L8240CDW Laser Printer
Direct network printing to the **Brother HL-L8240CDW** duplex color laser printer on the home network (`192.168.10.91:631`) via IPP Everywhere.
## 🎯 When to Use
- Whenever the user asks to print something: *"Bitte drucke XY"*, *"Druck das bitte aus"*, *"Kannst du das drucken?"*.
- Whenever the user sends a PDF, image, document, or ticket on Telegram with a request to print.
- Whenever the user asks to print recipes, shopping lists, flight tickets, packing lists, or notes.
- Whenever the user asks for printer status or toner levels: *"Wie viel Toner ist noch im Drucker?"*, *"Ist der Drucker online?"*.
---
## 🕹️ Interactive Telegram Options Protocol (CRITICAL)
When the user requests a print (e.g. *"Bitte drucke die Rechnung"* or sends a PDF), **DO NOT print blindly immediately**.
Provide an interactive decision prompt on Telegram using the `ask_user` tool with pre-configured defaults:
### Fast-Path Preset Menu via `ask_user`
Call `ask_user` with:
- `id`: `"print_options"`
- `header`: `"Drucker"`
- `question`: `"Wie möchtest du das Dokument drucken?"`
- `options`:
1. `label`: `"🌱 Spar-Druck: S/W, 2 Seiten, Duplex (Recommended)"`
`description`: `"Schwarz-Weiß, 2 Seiten pro Blatt, beidseitig (spart Papier)"`
*(Maps to: `--color monochrome --nup 2 --duplex two-sided-long-edge`)*
2. `label`: `"📄 Standard: S/W, 1 Seite, Duplex"`
`description`: `"Schwarz-Weiß, 1 Seite pro Blatt, beidseitig"`
*(Maps to: `--color monochrome --nup 1 --duplex two-sided-long-edge`)*
3. `label`: `"🎨 Vollfarbe: 1 Seite, Duplex"`
`description`: `"Farbdruck, 1 Seite pro Blatt, beidseitig"`
*(Maps to: `--color color --nup 1 --duplex two-sided-long-edge`)*
4. `label`: `"📄 Einseitig (Simplex): S/W, 1 Seite"`
`description`: `"Schwarz-Weiß, 1 Seite pro Blatt, nur Vorderseite"`
*(Maps to: `--color monochrome --nup 1 --duplex one-sided`)*
> [!TIP]
> If the user specifically asks for custom parameters (e.g. *"Druck das in Farbe auf 2 Seiten"*), parse the options directly and run without extra prompt.
---
## 🚀 Execution & Live Telegram Status Updates
Execute the bundled driver script with `uv run`.
Always pass `--chat-id <chat_id>` so the script automatically creates and live-updates a single Telegram message (`⏳ Bereite vor...` ➔ `📡 Sende an Drucker...` ➔ `🖨️ Druckt...` ➔ `✅ Erfolgreich gedruckt!`):
### 1. Print a PDF file with selected options
```bash
uv run /home/node/mop-skills/mop-skills/skills/brother-printer/scripts/brother_printer.py print \
--file "/home/node/.openclaw/workspace/document.pdf" \
--color monochrome \
--nup 2 \
--duplex two-sided-long-edge \
--chat-id "306373425"
```
### 2. Print a document from a URL
```bash
uv run /home/node/mop-skills/mop-skills/skills/brother-printer/scripts/brother_printer.py print \
--url "https://example.com/ticket.pdf" \
--title "Konzertticket" \
--color color \
--nup 1 \
--duplex two-sided-long-edge \
--chat-id "306373425"
```
### 3. Print plain text or notes
```bash
uv run /home/node/mop-skills/mop-skills/skills/brother-printer/scripts/brother_printer.py print \
--text "Einkaufsliste für Samstag:\n- Hafermilch\n- Kaffee\n- Bananen" \
--title "Einkaufszettel" \
--color monochrome \
--nup 1 \
--duplex one-sided \
--chat-id "306373425"
```
### 4. Check status & toner levels
```bash
uv run /home/node/mop-skills/mop-skills/skills/brother-printer/scripts/brother_printer.py status
```
---
## ⚙️ CLI Options Reference
| Argument | Values / Type | Default | Description |
|---|---|---|---|
| `--file` | File path | None | Local PDF path to print |
| `--url` | URL string | None | Download and print PDF from web |
| `--text` | String | None | Text content to format as A4 PDF and print |
| `--title` | String | Basename | Document title for print queue and Telegram header |
| `--color` | `monochrome`, `color` | `monochrome` | Print color mode |
| `--nup` | `1`, `2`, `4` | `1` | Hardware pages per sheet (1 = normal, 2 = 2 pages/sheet) |
| `--duplex` | `two-sided-long-edge`, `one-sided`, `two-sided-short-edge` | `two-sided-long-edge` | Duplex binding mode |
| `--copies` | Integer | `1` | Number of printed copies |
| `--chat-id` | Telegram Chat ID | None | Target chat ID for live status updates |
| `--max-pages`| Integer | `30` | Safety limit to prevent runaway printing |
| `--force` | Flag | `false` | Bypass page limit |
+475
View File
@@ -0,0 +1,475 @@
#!/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, message_id: Optional[int] = None):
self.token = token or TELEGRAM_BOT_TOKEN
self.chat_id = chat_id
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",
}
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", "<br/>")
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, 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("--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()