Lazy tool to rasterize text and send to a M02 printer
  • Go 76.2%
  • Python 20%
  • Shell 3.8%
Find a file
Violet Heague 9a63edf621
All checks were successful
Release / linux (push) Successful in 1m7s
Release / macos (push) Successful in 21s
Keep the status dot honest after recovery
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>
2026-06-04 11:02:34 +10:00
.forgejo/workflows Manual MacOS attach 2026-05-29 11:14:05 +10:00
fonts Convert to a GO binary to avoid MacOS permission issues 2026-05-29 10:52:35 +10:00
macos Convert to a GO binary to avoid MacOS permission issues 2026-05-29 10:52:35 +10:00
.gitignore Add Licence 2026-05-29 10:57:40 +10:00
build.sh Convert to a GO binary to avoid MacOS permission issues 2026-05-29 10:52:35 +10:00
go.mod Recover when Bluetooth is off at first use 2026-06-04 11:01:58 +10:00
go.sum Recover when Bluetooth is off at first use 2026-06-04 11:01:58 +10:00
LICENSE Add Licence 2026-05-29 10:57:40 +10:00
main.go Keep the status dot honest after recovery 2026-06-04 11:02:34 +10:00
print.py Print script 2026-05-28 16:55:10 +10:00
printer.go Recover when Bluetooth is off at first use 2026-06-04 11:01:58 +10:00
raster.go Add web UI with live wrap preview to /print 2026-06-04 11:00:36 +10:00
README.md Add printer status endpoint and web UI indicator 2026-06-04 11:01:32 +10:00

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=N font size in points (default 24)
    • rotate=false disable the default 180° rotation
    • wrap=false preserve 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 (rotate defaults to false here 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 — returns ok (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:PORT listen address (default 127.0.0.1:8472)
  • -name NAME Bluetooth device name (default Mr.in_M02)
  • -size N default font size in points (default 24)
  • -no-rotate don't rotate 180° by default
  • -text "..." print once and exit instead of serving (macOS: only works inside the .app)
  • -preview FILE.png render -text to 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 NSBluetoothAlwaysUsageDescription in its responsible app's Info.plist, or it is killed with SIGABRT on first BLE call. That's why the server ships as a signed .app and 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.