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).
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.egg-info/
|
||||||
|
GITEA_API.md
|
||||||
|
.backup/
|
||||||
@@ -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.
|
||||||
@@ -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: <decision> — <why>`
|
||||||
|
|
||||||
|
## 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
|
||||||
|
<!-- Decisions NOT to do something are just as valuable -- prevents re-proposing rejected ideas -->
|
||||||
|
|
||||||
|
- 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.
|
||||||
@@ -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`).
|
||||||
@@ -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`.
|
||||||
@@ -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"]
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user