commit 421c3c96600ed25cc1f4b8f766eda5972efc49a8 Author: Mortdecai Date: Mon May 25 16:51:17 2026 -0400 feat: initial MCP server for printing markdown to Epson TM-m30 Single tool `print(markdown, title?, timestamp?, qr_urls?, cut?)`. Hand-rolled line-by-line markdown parser translates headings/bold/code/lists/blockquotes/ HR/links to ESC/POS, then sends raw bytes via TCP to 192.168.0.184:9100. Mirrors mcp-gemma4 layout (stdio server, single server.py, mcp package). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..489fc3a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +*.egg-info/ +GITEA_API.md +.backup/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b2a28c5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,28 @@ +# mcp-pos-print + +> MCP server that prints markdown to the Epson TM-m30 receipt printer. + +## Start Here + +**Read the latest handoff first:** `.claude/handoffs/` (most recent file). +It has session state, in-progress work, and ordered next steps. + +Then check `IDEA.md` for the project brief and `DECISIONS.md` for settled choices. + +## Project Identity + +Stdio MCP server exposing one tool, `print`, that takes a markdown string (plus optional title, timestamp, and QR URL list) and sends ESC/POS bytes to the TM-m30 at `192.168.0.184:9100`. Mirrors the `mcp-gemma4` layout — single `server.py`, stdio transport, registered in `~/.claude.json`. The server owns markdown → ESC/POS translation; callers do not need to know any printer-specific schema. + +## Current State + +- **Phase:** building +- **Repo:** git.sethpc.xyz/Seth/mcp-pos-print (planned) +- **Deploy target:** invoked locally by Claude Code on whichever host has LAN access to `192.168.0.184` (typically steel141) +- **Status:** initial scaffold + +## Conventions + +- See `~/bin/POS_PRINT.md` for the printer reference (IP, port, Font B columns, native QR usage). +- See `~/bin/mcp-gemma4/server.py` for the MCP layout pattern to mirror. +- Env overrides: `POS_PRINTER_IP`, `POS_PRINTER_PORT`, `POS_PRINTER_TIMEOUT`. +- Always Font B (`font='b'`, 57 cols) — Font A column math is wrong for 80mm paper at the default size. diff --git a/DECISIONS.md b/DECISIONS.md new file mode 100644 index 0000000..7b46a96 --- /dev/null +++ b/DECISIONS.md @@ -0,0 +1,26 @@ +# DECISIONS.md — mcp-pos-print Decision Log + +Project-specific decisions. For global/cross-cutting decisions, see `~/bin/DECISIONS.md`. + +Format: `YYYY-MM-DD: ` + +## Architecture + +- 2026-05-25: stdio MCP server, single file (`server.py`), mirrors `mcp-gemma4` pattern — Seth already runs MCPs this way; no point inventing a new layout. +- 2026-05-25: Single `print` tool taking markdown — Claude already speaks markdown fluently, no printer-specific schema to learn on the call side. Server owns the markdown→ESC/POS translation. +- 2026-05-25: Hand-rolled line-by-line markdown parser, no `markdown-it-py` dependency — our subset (headings, bold, hr, lists, code blocks, links, blockquotes) is small enough that a dependency would be heavier than the parser. +- 2026-05-25: Font B (57 cols) hard-coded — POS_PRINT.md mandates it for the TM-m30 80mm paper. + +## Implementation + +- 2026-05-25: `qr_urls` kwarg (list[str]) for QR codes at end of receipt — keeps the markdown body pure text and avoids inventing a custom QR syntax. +- 2026-05-25: Env overrides `POS_PRINTER_IP` / `POS_PRINTER_PORT` / `POS_PRINTER_TIMEOUT` — match the conventions documented in `~/bin/POS_PRINT.md`. + +## Deferred / Rejected + + +- 2026-05-25: **Rejected** image printing (base64 PIL) — no use case proposed; can add a `print_image` tool later if it actually comes up. YAGNI. +- 2026-05-25: **Rejected** separate `print_qr` / `print_barcode` / `print_separator` primitive tools — would force Claude to make many tool calls per receipt (chatty, printer drains between calls). One tool + markdown + optional `qr_urls` covers the case. +- 2026-05-25: **Rejected** drawer-kick / buzzer / multi-receipt batching — no drawer attached, no buzzer attached, no multi-receipt workflow exists. +- 2026-05-25: **Rejected** auto-QR for any link in markdown body — surprising behavior, would produce a wall of QR codes for documents with many links. Explicit `qr_urls` only. +- 2026-05-25: **Rejected** truncation of large markdown input — Claude is the one deciding what to print; if it sends a long document, print it. Paper is cheap. diff --git a/IDEA.md b/IDEA.md new file mode 100644 index 0000000..8a2204c --- /dev/null +++ b/IDEA.md @@ -0,0 +1,16 @@ +# IDEA.md — mcp-pos-print + +## What is this? + +An MCP server that exposes the Epson TM-m30 receipt printer (192.168.0.184) as a single tool to any Claude Code session. Claude calls `print` with a markdown string, the server translates markdown → ESC/POS and sends raw bytes over TCP to the printer. + +## Problem it solves + +When working with Claude on the homelab box, useful output sometimes belongs on paper — a code snippet to keep at the desk, a quick summary, a TODO list, a QR for a URL. Currently this requires writing a one-off Python script that imports `escpos` and `socket`. With this MCP, Claude just says "let me print that" and it comes out of the printer with no extra plumbing. + +## Constraints / preferences + +- Python (matches existing `mcp-gemma4` pattern in this repo). +- stdio transport (matches existing pattern; local-only invocation, no network exposure of the MCP itself). +- Single tool, minimal surface — markdown in, paper out. +- Mirrors conventions documented in `~/bin/POS_PRINT.md` (Font B, 57 cols, native QR, TCP to `192.168.0.184:9100`). diff --git a/README.md b/README.md new file mode 100644 index 0000000..46321fb --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# mcp-pos-print + +MCP server that prints markdown to the Epson TM-m30 receipt printer at `192.168.0.184:9100`. Mirrors the [`mcp-gemma4`](../mcp-gemma4/) layout. + +See `~/bin/POS_PRINT.md` for the printer reference (Font B columns, native QR, troubleshooting) and `DECISIONS.md` for design rationale. + +## Tool + +`print(markdown, title?, timestamp?, qr_urls?, cut?)` + +- **`markdown`** *(required)* — body to print. Supports `# / ## / ###` headings, `**bold**` and `` `code` `` inline, fenced ```` ``` ```` code blocks (no wrap), `---` / `***` horizontal rules, `-` / `*` / `1.` lists, `> blockquotes`, `[text](url)` links, blank lines. Long lines word-wrap at 57 cols. +- **`title`** — bold centered header above the body. +- **`timestamp`** — bool, default `true`. Prints `Mon, May 25 14:32 ET` under title. +- **`qr_urls`** — list of URLs printed as native hardware QR codes at the end. +- **`cut`** — bool, default `true`. Auto-cut paper. + +Returns `Printed N bytes to 192.168.0.184:9100.` on success, or a clear error string if the printer is unreachable. + +## Install + +```bash +cd ~/bin/mcp-pos-print +python3 -m venv .venv +.venv/bin/pip install -e . +``` + +Register in `~/.claude.json` (global scope): + +```json +{ + "mcpServers": { + "pos-print": { + "command": "/home/claude/bin/mcp-pos-print/.venv/bin/python", + "args": ["/home/claude/bin/mcp-pos-print/server.py"] + } + } +} +``` + +Restart Claude Code or run `/mcp` to reconnect. + +## Test + +```bash +.venv/bin/python test_server.py +``` + +Prints a self-test page covering headings, inline formatting, lists, code blocks, blockquotes, horizontal rules, and a QR code. Requires the printer reachable at `192.168.0.184:9100`. + +## Config + +- `POS_PRINTER_IP` — default `192.168.0.184` +- `POS_PRINTER_PORT` — default `9100` +- `POS_PRINTER_TIMEOUT` — TCP timeout in seconds, default `10` + +## Markdown subset + +Hand-rolled line-by-line parser, no markdown-it-py dependency. Supported: + +| Markdown | Output | +|---|---| +| `# Heading` | bold + 2x size, centered | +| `## Heading` / `### Heading` | bold, left | +| `**bold**` inline | bold run | +| `` `code` `` inline | passed through (already monospace) | +| ```` ```fenced``` ```` | preserved verbatim, no wrap; `»` marks chars truncated past col 57 | +| `---` / `***` / `___` | thin separator across full width | +| `- item` / `* item` | `* item`, hanging-indented continuation | +| `1. item` | `1. item`, hanging-indented continuation | +| `> quote` | `\| ` prefix | +| `[text](url)` | `text (url)` | +| blank line | blank line | +| anything else | word-wrapped at 57 cols | + +Not supported (by design): images, tables, nested lists, footnotes, HTML. Use `qr_urls` for QR codes — there's no QR markdown syntax. + +## Troubleshooting + +- **`cannot reach printer`** — power, network, paper, or ARP issue. `ping 192.168.0.184` first. +- **`timed out`** — same. The TM-m30's WiFi sometimes drops ARP entries; a ping usually re-resolves. +- **Garbled output** — most likely you mixed an external script that uses Font A; this server hard-codes Font B (57 cols). If you're seeing it from `print`, file a bug. +- **No paper feed at end** — make sure `cut` isn't explicitly `false`. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4be3a15 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "mcp-pos-print" +version = "0.1.0" +description = "MCP server that prints markdown to the Epson TM-m30 receipt printer (192.168.0.184)." +requires-python = ">=3.10" +dependencies = [ + "mcp>=1.0.0", + "python-escpos>=3.0", + "qrcode>=7.0", +] + +[tool.setuptools] +py-modules = ["server"] diff --git a/server.py b/server.py new file mode 100644 index 0000000..ae6a716 --- /dev/null +++ b/server.py @@ -0,0 +1,403 @@ +"""MCP server exposing the Epson TM-m30 receipt printer as a `print` tool. + +Takes markdown, translates it to ESC/POS, sends raw bytes over TCP to the +printer at $POS_PRINTER_IP:$POS_PRINTER_PORT (default 192.168.0.184:9100). + +See DECISIONS.md for design rationale and ~/bin/POS_PRINT.md for the printer +reference (Font B columns, native QR, troubleshooting). +""" + +import asyncio +import os +import re +import socket +from datetime import datetime +from typing import Any +from zoneinfo import ZoneInfo + +from escpos.printer import Dummy +from mcp.server import Server +from mcp.server.stdio import stdio_server +from mcp.types import TextContent, Tool + +PRINTER_IP = os.environ.get("POS_PRINTER_IP", "192.168.0.184") +PRINTER_PORT = int(os.environ.get("POS_PRINTER_PORT", "9100")) +PRINTER_TIMEOUT = int(os.environ.get("POS_PRINTER_TIMEOUT", "10")) +COLS = 57 # Font B on 80mm paper. See POS_PRINT.md. +EASTERN = ZoneInfo("America/New_York") + +server: Server = Server("pos-print") + +TOOL_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "markdown": { + "type": "string", + "description": ( + "The body to print, as markdown. Supports: # / ## / ### headings, " + "**bold** and `code` inline, fenced ``` code blocks (no wrap), " + "--- or *** horizontal rules, - / * / 1. lists, > blockquotes, " + "[text](url) links, blank lines. Long lines word-wrap at 57 columns." + ), + }, + "title": { + "type": "string", + "description": "Optional bold centered header printed above the body.", + }, + "timestamp": { + "type": "boolean", + "default": True, + "description": "Print a date/time line under the title (Eastern Time).", + }, + "qr_urls": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional list of URLs to render as native hardware QR codes at the end.", + }, + "cut": { + "type": "boolean", + "default": True, + "description": "Auto-cut the paper at the end. Almost always leave true.", + }, + }, + "required": ["markdown"], +} + + +@server.list_tools() +async def list_tools() -> list[Tool]: + return [ + Tool( + name="print", + description=( + "Print arbitrary markdown to the Epson TM-m30 receipt printer. " + "Use for code snippets, summaries, TODO lists, QR codes, or any " + "output the user wants on paper. Returns confirmation with byte " + "count, or a clear error if the printer is unreachable." + ), + inputSchema=TOOL_SCHEMA, + ) + ] + + +# ── Inline markdown parser ─────────────────────────────────────────────────── +# Splits a line into [(style, text), ...] where style is one of: +# "plain", "bold", "code" +# Supports: **bold**, `code`, [text](url) -> "text (url)" + +_INLINE_RE = re.compile( + r"(\*\*[^*\n]+?\*\*|`[^`\n]+?`|\[[^\]\n]+?\]\([^)\n]+?\))" +) + + +def parse_inline(line: str) -> list[tuple[str, str]]: + """Parse a single line into styled segments. Order-preserving.""" + if not line: + return [("plain", "")] + out: list[tuple[str, str]] = [] + for part in _INLINE_RE.split(line): + if not part: + continue + if part.startswith("**") and part.endswith("**"): + out.append(("bold", part[2:-2])) + elif part.startswith("`") and part.endswith("`"): + out.append(("code", part[1:-1])) + elif part.startswith("[") and "](" in part and part.endswith(")"): + close = part.index("]") + text = part[1:close] + url = part[close + 2 : -1] + out.append(("plain", f"{text} ({url})")) + else: + out.append(("plain", part)) + return out + + +# ── Word wrap that respects styled segments ────────────────────────────────── +def wrap_segments( + segments: list[tuple[str, str]], width: int, indent: str = "" +) -> list[list[tuple[str, str]]]: + """Wrap a styled line into multiple styled lines, never breaking mid-word + where avoidable. First line uses no extra indent; continuation lines use + `indent`. Returns a list of styled-segment lines.""" + lines: list[list[tuple[str, str]]] = [[]] + col = 0 + first = True + + def line_width() -> int: + return width if first else max(1, width - len(indent)) + + for style, text in segments: + # Split on whitespace but preserve the whitespace runs so we can rejoin. + tokens = re.split(r"(\s+)", text) + for tok in tokens: + if not tok: + continue + if tok.isspace(): + # Whitespace: only add if not at start of line. + if col > 0: + lines[-1].append((style, " ")) + col += 1 + continue + # Hard-break tokens longer than the line width by char. + while len(tok) > line_width() - col and col > 0: + lines.append([]) + col = 0 + first = False + while len(tok) > line_width(): + head, tok = tok[: line_width()], tok[line_width() :] + lines[-1].append((style, head)) + lines.append([]) + col = 0 + first = False + lines[-1].append((style, tok)) + col += len(tok) + + # Apply continuation indent + if indent and len(lines) > 1: + for i in range(1, len(lines)): + lines[i] = [("plain", indent)] + lines[i] + + # Drop trailing empty lines + while lines and not lines[-1]: + lines.pop() + return lines or [[]] + + +def emit_styled_line(p: Dummy, segs: list[tuple[str, str]]) -> None: + """Emit a single styled line (no trailing newline).""" + for style, text in segs: + if not text: + continue + if style == "bold": + p.set(font="b", bold=True) + p.text(text) + p.set(font="b", bold=False) + else: + # 'code' and 'plain' both print as plain Font B; ESC/POS has no + # monospace toggle (already monospace) and no background shading + # worth using on a 57-col receipt. + p.set(font="b", bold=False) + p.text(text) + + +# ── Block-level markdown parser ────────────────────────────────────────────── +_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$") +_HR_RE = re.compile(r"^\s*(?:-{3,}|\*{3,}|_{3,})\s*$") +_BULLET_RE = re.compile(r"^\s*[-*]\s+(.+?)\s*$") +_NUMBERED_RE = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$") +_QUOTE_RE = re.compile(r"^\s*>\s?(.*)$") +_FENCE_RE = re.compile(r"^\s*```") + + +def render_markdown(p: Dummy, md: str) -> None: + """Walk markdown line by line and emit ESC/POS via the Dummy builder.""" + lines = md.splitlines() + i = 0 + while i < len(lines): + line = lines[i] + + # Fenced code block: print verbatim, no wrap, until closing fence. + if _FENCE_RE.match(line): + i += 1 + while i < len(lines) and not _FENCE_RE.match(lines[i]): + p.set(font="b", bold=False, align="left") + # Truncate over-width code lines with a continuation marker. + text = lines[i] + while len(text) > COLS: + p.text(text[: COLS - 1] + "»\n") # » continuation marker + text = text[COLS - 1 :] + p.text(text + "\n") + i += 1 + i += 1 # consume the closing fence (or end of input) + continue + + # Horizontal rule + if _HR_RE.match(line): + p.set(font="b", bold=False, align="left") + p.text("-" * COLS + "\n") + i += 1 + continue + + # Headings + m = _HEADING_RE.match(line) + if m: + level = len(m.group(1)) + text = m.group(2) + if level == 1: + p.set(font="b", bold=True, align="center", width=2, height=2) + p.text(text + "\n") + p.set(font="b", bold=False, align="left", width=1, height=1) + elif level == 2: + p.set(font="b", bold=True, align="left") + p.text(text + "\n") + p.set(font="b", bold=False, align="left") + else: + p.set(font="b", bold=True, align="left") + p.text(text + "\n") + p.set(font="b", bold=False, align="left") + i += 1 + continue + + # Blockquote + m = _QUOTE_RE.match(line) + if m: + inner = m.group(1) + segs = parse_inline(inner) + wrapped = wrap_segments(segs, COLS - 2, indent=" ") + for wline in wrapped: + p.set(font="b", bold=False, align="left") + p.text("| ") + emit_styled_line(p, wline) + p.text("\n") + i += 1 + continue + + # Bullet list item + m = _BULLET_RE.match(line) + if m: + inner = m.group(1) + segs = parse_inline(inner) + wrapped = wrap_segments(segs, COLS - 2, indent=" ") + for j, wline in enumerate(wrapped): + p.set(font="b", bold=False, align="left") + p.text("* " if j == 0 else "") + emit_styled_line(p, wline) + p.text("\n") + i += 1 + continue + + # Numbered list item + m = _NUMBERED_RE.match(line) + if m: + num = m.group(1) + inner = m.group(2) + prefix = f"{num}. " + segs = parse_inline(inner) + wrapped = wrap_segments(segs, COLS - len(prefix), indent=" " * len(prefix)) + for j, wline in enumerate(wrapped): + p.set(font="b", bold=False, align="left") + p.text(prefix if j == 0 else "") + emit_styled_line(p, wline) + p.text("\n") + i += 1 + continue + + # Blank line + if not line.strip(): + p.set(font="b", bold=False, align="left") + p.text("\n") + i += 1 + continue + + # Plain paragraph line — wrap and emit with inline styles. + segs = parse_inline(line) + wrapped = wrap_segments(segs, COLS) + for wline in wrapped: + p.set(font="b", bold=False, align="left") + emit_styled_line(p, wline) + p.text("\n") + i += 1 + + +# ── Receipt builder ────────────────────────────────────────────────────────── +def build_receipt( + markdown: str, + title: str | None, + timestamp: bool, + qr_urls: list[str] | None, + cut: bool, +) -> bytes: + p = Dummy(profile="default") + + if title: + p.set(font="b", bold=True, align="center", width=2, height=2) + p.text(title + "\n") + p.set(font="b", bold=False, align="center", width=1, height=1) + if timestamp: + p.set(font="b", bold=False, align="center") + p.text(datetime.now(EASTERN).strftime("%a, %b %d %H:%M ET") + "\n") + if title or timestamp: + p.set(font="b", bold=False, align="left") + p.text("=" * COLS + "\n") + + render_markdown(p, markdown) + + if qr_urls: + p.set(font="b", bold=False, align="left") + p.text("-" * COLS + "\n") + for url in qr_urls: + p.set(font="b", bold=False, align="center") + p.qr(url, native=True, size=6) + p.set(font="b", bold=False, align="center") + p.text(url + "\n") + p.text("\n") + + if cut: + p.set(font="b", bold=False, align="left") + p.text("\n") + p.cut() + + return p.output + + +def send_bytes(raw: bytes) -> None: + """Send raw ESC/POS bytes to the printer over TCP. Raises on failure.""" + with socket.create_connection( + (PRINTER_IP, PRINTER_PORT), timeout=PRINTER_TIMEOUT + ) as sock: + sock.sendall(raw) + + +@server.call_tool() +async def call_tool(name: str, args: dict[str, Any]) -> list[TextContent]: + if name != "print": + return [TextContent(type="text", text=f"Error: unknown tool `{name}`.")] + + markdown = args.get("markdown") + if not markdown or not str(markdown).strip(): + return [TextContent(type="text", text="Error: `markdown` is empty — nothing to print.")] + + title = args.get("title") + timestamp = bool(args.get("timestamp", True)) + qr_urls = args.get("qr_urls") or [] + cut = bool(args.get("cut", True)) + + def _build_and_send() -> tuple[int | None, str | None]: + try: + raw = build_receipt(str(markdown), title, timestamp, list(qr_urls), cut) + except Exception as e: + return None, f"Error: failed to build receipt bytes: {type(e).__name__}: {e}" + try: + send_bytes(raw) + except (TimeoutError, socket.timeout): + return ( + None, + f"Error: printer at {PRINTER_IP}:{PRINTER_PORT} timed out after " + f"{PRINTER_TIMEOUT}s. Likely off, paper out, or ARP issue — ping first.", + ) + except OSError as e: + return ( + None, + f"Error: cannot reach printer at {PRINTER_IP}:{PRINTER_PORT} " + f"({e}). Check power, network, paper.", + ) + return len(raw), None + + byte_count, err = await asyncio.to_thread(_build_and_send) + if err: + return [TextContent(type="text", text=err)] + return [ + TextContent( + type="text", + text=f"Printed {byte_count} bytes to {PRINTER_IP}:{PRINTER_PORT}.", + ) + ] + + +async def main() -> None: + async with stdio_server() as (read, write): + await server.run(read, write, server.create_initialization_options()) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/test_server.py b/test_server.py new file mode 100644 index 0000000..af32046 --- /dev/null +++ b/test_server.py @@ -0,0 +1,91 @@ +"""Self-test for mcp-pos-print. + +Runs the same code path as the MCP `print` tool (build_receipt + send_bytes) +without spinning up an MCP server. Prints one self-documenting page covering +every supported markdown feature plus a QR code. + +Usage: .venv/bin/python test_server.py +""" + +import sys + +from server import build_receipt, send_bytes, PRINTER_IP, PRINTER_PORT + + +SELF_TEST_MD = """\ +# mcp-pos-print + +## Self test + +This page exercises every supported markdown feature. If anything here looks +broken on the printout, the parser has a bug. + +### Inline formatting + +Plain text. **Bold text**. `inline code`. A [link to example](https://example.com) +that gets rendered as text + url in parens. Mixed: this **bold spans `inline +code` and continues** after the code. + +### Lists + +- First bullet, short. +- Second bullet that runs long enough to wrap onto a continuation line so we + can verify the hanging indent works as expected at 57 columns. +- Third bullet with **bold** and `code`. + +1. Numbered one. +2. Numbered two with a longer body that exercises hanging-indent for the + numbered variant which has a different prefix width. +3. Numbered three. + +### Blockquote + +> Single-line quote. + +> Long quote that needs to wrap. The pipe prefix should appear on the first +> line; continuation lines indent under the text, not under the pipe. + +### Horizontal rule + +--- + +### Code block + +``` +def hello(name): + print(f"hi, {name}") + return 42 +``` + +### Long line + +This is a single very long paragraph line that should soft-wrap at the 57 +column boundary determined by Font B on 80mm thermal paper, demonstrating +that the wrap algorithm prefers word boundaries over hard breaks. + +### End + +If all of the above rendered correctly you are good to go. +""" + + +def main() -> int: + raw = build_receipt( + markdown=SELF_TEST_MD, + title="mcp-pos-print", + timestamp=True, + qr_urls=["https://git.sethpc.xyz/Seth/mcp-pos-print"], + cut=True, + ) + print(f"Built {len(raw)} bytes. Sending to {PRINTER_IP}:{PRINTER_PORT}...") + try: + send_bytes(raw) + except Exception as e: + print(f"FAIL: {type(e).__name__}: {e}", file=sys.stderr) + return 1 + print("OK — check the printer.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())