- Go 76.2%
- Python 20%
- Shell 3.8%
The dot could stay red long after the printer came back — or while a print was succeeding — because of stacked staleness: a flat 10s status cache, a 15s poll interval, and print outcomes never feeding the cache (a print connects straight to the cached address, no scan, so it can work while a scan-based ping still reports offline). Now a print attempt records its outcome in the status cache directly, offline results expire after 3s instead of 10s, and the page polls every 5s while red (15s when green). Worst-case time for the dot to go green after recovery drops from ~25s to ~8s, and to ~0 after a print. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .forgejo/workflows | ||
| fonts | ||
| macos | ||
| .gitignore | ||
| build.sh | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| main.go | ||
| print.py | ||
| printer.go | ||
| raster.go | ||
| README.md | ||
m02-print
Warning
This repo is entirely generated by an LLM - I have not reviewed the code, but the tool worked for my uses! Be warned!
Print plain text on a Phomemo M02 thermal printer over Bluetooth LE.
Two ways to use it:
m02print— a Go HTTP server (the main tool). One long-lived process holds the Bluetooth permission and prints whatever you POST to it. Mac and Linux.print.py— a standalone Python CLI for one-off prints. Simpler, but on macOS each terminal needs its own Bluetooth permission (see notes).
Server (Go)
Build:
./build.sh
On macOS this produces dist/M02 Print Server.app, a code-signed background app
carrying the NSBluetoothAlwaysUsageDescription it needs. On Linux it produces
the bare dist/m02print binary.
Run (macOS):
open "dist/M02 Print Server.app" # grant Bluetooth when macOS asks
Run (Linux):
./dist/m02print
Then print from anything, no per-app Bluetooth permission required:
curl --data-binary "hello" http://127.0.0.1:8472/print
curl --data-binary @note.txt http://127.0.0.1:8472/print
curl --data-binary "big" "http://127.0.0.1:8472/print?size=48&rotate=false"
HTTP API
POST /print— body is the text to print. Query params:size=Nfont size in points (default 24)rotate=falsedisable the default 180° rotationwrap=falsepreserve lines and spaces verbatim (for ASCII art)
GET /print— a small web form: textbox, font size, print button, and a live preview showing exactly how the text will wrap.POST /preview— same body and query params as/print, but returns the rendered PNG instead of printing (rotatedefaults tofalsehere so the preview reads upright in a browser).GET /status— returns{"online":true|false}: whether the printer is reachable (a short BLE scan for its advertisement, cached for 10 s). The web form shows this as a green/red dot.GET /healthz— returnsok(the server is alive; says nothing about the printer).
Line endings: \r\n and \r in the body are normalized to \n, and \n
starts a new line on the slip.
ASCII art / fixed-width output
Use wrap=false so whitespace and line breaks are kept exactly. The head is
384 dots wide; characters that fit per line depend on font size (Go Mono):
| size | columns | size | columns | |
|---|---|---|---|---|
| 8 | 73 | 20 | 30 | |
| 10 | 61 | 24 | 26 | |
| 12 | 52 | 32 | 19 | |
| 16 | 36 | 48 | 12 |
curl --data-binary @cat.txt "http://127.0.0.1:8472/print?wrap=false&size=16"
Lines wider than the column limit clip at the right edge.
Flags
-addr HOST:PORTlisten address (default127.0.0.1:8472)-name NAMEBluetooth device name (defaultMr.in_M02)-size Ndefault font size in points (default 24)-no-rotatedon't rotate 180° by default-text "..."print once and exit instead of serving (macOS: only works inside the .app)-preview FILE.pngrender-textto a PNG and exit, without printing
Auto-start at login (macOS)
System Settings → General → Login Items → add M02 Print Server.app. Login
Items launches it the same way open does, so the Bluetooth grant carries over.
CLI (Python)
./print.py "your text"
echo "from stdin" | ./print.py
Flags: --size N, --font PATH, --name NAME, --no-rotate, --preview PNG,
--dry-run. Needs uv on PATH; pillow + bleak are declared inline (PEP 723)
and resolved on first run.
How it works
The M02 doesn't rasterize anything itself. Text is rendered into a 384-pixel-wide
1-bit image. The Go server renders each rune in Go Mono when it has the glyph and
falls back to embedded GNU Unifont otherwise, so kana, kanji, kaomoji symbols and
the like print instead of showing tofu boxes. The image is packed
row-major MSB-first with bit=1 meaning a black dot, and wrapped in the printer's
ESC/POS-ish image-block framing. Any 0x0A byte in the bitmap is rewritten to
0x14 — the firmware reads a lone 0x0A as a line feed and desyncs. The payload
is streamed to characteristic ff02 on service ff00; the printer reports status
on ff03 and emits a 1A 0F … frame when the job is done, which we wait for
before disconnecting (closing mid-job aborts the print).
Connectivity notes (hard-won)
- BLE is the only path on macOS. USB enumerates only a Jieli debug CDC serial,
not the print interface. Classic Bluetooth SPP (
/dev/cu.Mrin_M02) accepts bytes but routes them to a non-print RFCOMM channel — looks like it works, prints nothing. - macOS Bluetooth is gated per-app (TCC). A process touching CoreBluetooth needs
an
NSBluetoothAlwaysUsageDescriptionin its responsible app'sInfo.plist, or it is killed withSIGABRTon first BLE call. That's why the server ships as a signed.appand must be launched via LaunchServices (open/ Login Items), not exec'd directly. The HTTP front end exists so callers don't each need this grant. - Linux (BlueZ) has no such gating; the bare binary just works.
License
This project's code is licensed under the MIT License — see LICENSE.
Copyright (c) 2026 pfych.
Bundled fonts are third-party components and keep their own licenses (not covered by the MIT license above):
- Go Mono — BSD-licensed, via
golang.org/x/image/font/gofont. - GNU Unifont (
fonts/unifont.otf) — dual-licensed OFL-1.1 / GPLv2+ with the GNU font embedding exception.