Markdown-to-ESC/POS printer stack for a MASUNG M_ONE_TYPE thermal printer, with a barcode scanner trigger and a remote admin panel.
| File | Purpose |
|---|---|
print_md.py |
Convert Markdown to ESC/POS and send via CUPS |
scan_trigger.py |
Watch barcode scanner, fire print job on coord.info scan |
admin.py |
Flask admin panel (service control, printer status, logs) |
printer-scanner.service |
systemd unit for scan_trigger.py |
printer-admin.service |
systemd unit for admin.py |
geotour.md |
Logbook template (uses {{username}} and {{datetime}}) |
nice_try.md |
Printed when a coord.info scan yields no username |
pip install mistune pillow pycups evdev flask qrcode beautifulsoup4 requestsTwo raw queues are expected — POS-RAW (receipts) and Lable (labels).
Both point to the same MASUNG device. Check with:
lpstat -vThe scanner is a USB HID keyboard device. The running user needs read access:
sudo usermod -aG input $USER
# log out and back in for the group to take effectsudo cp printer-scanner.service printer-admin.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now printer-scanner printer-adminThe admin panel calls systemctl to restart the scanner service. Add a
passwordless rule:
sudo visudo -f /etc/sudoers.d/printer-adminPaste this line:
kalle ALL=(ALL) NOPASSWD: /bin/systemctl restart printer-scanner, /bin/systemctl stop printer-scanner, /bin/systemctl start printer-scanner
The Pi has no static IP. Tailscale creates a private mesh network so you can reach it from anywhere without port forwarding.
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
# authenticate once via the URL it printsAfter that, SSH and the admin panel are accessible from any device on your Tailscale network using the Pi's Tailscale hostname or IP.
Access at http://<tailscale-ip>:5000 from any device on your Tailscale network.
Shows:
- Scanner service status with Restart / Stop / Start buttons
- Printer status for each CUPS queue — highlights stopped state and PAPER EMPTY
- Recent print jobs (last 5 completed)
- Scanner log (last 40 lines from journald, auto-refreshes every 30 s)
To view logs from the command line:
journalctl -u printer-scanner -f # live
journalctl -u printer-admin -fpython3 print_md.py [file] [options]| Option | Default | Description |
|---|---|---|
file |
stdin | Markdown file path, or - for stdin |
-p, --printer |
POS-RAW |
CUPS printer queue name |
-w, --width |
60 |
Paper width in mm: 60, 80, 82 |
--var KEY=VALUE |
— | Template variable (repeatable) |
--preview |
off | Hex-dump ESC/POS bytes, do not print |
python3 print_md.py note.md
echo "# Hello" | python3 print_md.py -
python3 print_md.py -p Lable -w 80 note.md
python3 print_md.py --var username=Alice note.md
python3 print_md.py --preview note.mdListens on the USB barcode scanner. When a https://coord.info/XXXX QR code
is scanned, it fetches the geocaching profile page, extracts the username from
the <h1>, and fires a print job.
python3 scan_trigger.py [-f FILE] [-p PRINTER] [-w WIDTH]| Scan result | Action |
|---|---|
| Not a coord.info URL | Ignored |
| Network error | Skipped |
HTTP 404 or no h1 found |
Prints nice_try.md |
| Username found | Prints template with {{username}} filled in |
# H1 — centered, double width + height, bold
## H2 — double height, bold
### H3 — bold + underline#### and deeper are treated the same as ###.
**bold text**
*italic text* ← printed as bold (no italic in ESC/POS)
`inline code` ← smaller monospace font---Printed as a full-width dashed separator line. Always put a blank line before
--- — without one, the preceding paragraph is parsed as an H2 heading.
- unordered item
- another item
- nested item
1. ordered item
2. second itemNested lists are indented by two spaces per level.
```
some code here
```Or indent with 4 spaces. Printed in smaller Font B with 2-space indent.
> quoted textPrinted in smaller Font B.

- Supported formats: PNG, JPEG, BMP, GIF, WEBP (anything Pillow can open)
- Scaled to full paper width, aspect ratio preserved
- Floyd-Steinberg dithering to 1-bit
- Relative paths resolve from the Markdown file's directory
{{key}} placeholders are substituted before parsing. Built-in variables:
| Placeholder | Value |
|---|---|
{{datetime}} |
Current date and time: 06.05.2026 20:00 |
Custom variables are passed with --var:
**Handle:** {{username}}
**Printed:** {{datetime}}python3 print_md.py logbook.md --var username=hywel93[link text](https://example.com)Only the link text is printed; the URL is discarded.
- Tables
- Footnotes
- Inline HTML (silently skipped)
- Remote image URLs (local files only)