Lazy tool to rasterize text and send to a M02 printer
  • Go 85.2%
  • Python 12.4%
  • Shell 2.4%
Find a file
Violet Heague 873d8ab080
All checks were successful
Release / linux (push) Successful in 1m36s
Release / macos (push) Successful in 27s
Print image and caption together in one job
A multipart form (image file field and/or text field) prints the image
above the text: curl -F image=@photo.png -F text=caption. Rendering
splits from packing (renderText/renderImage) and compose stacks the
parts, so the web UI caption box, CLI -image + -text, and /preview all
share one path.
2026-07-20 12:34:23 +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
image.go Print image and caption together in one job 2026-07-20 12:34:23 +10:00
LICENSE Add Licence 2026-05-29 10:57:40 +10:00
main.go Print image and caption together in one job 2026-07-20 12:34:23 +10:00
print.py Print script 2026-05-28 16:55:10 +10:00
printer.go Add battery and paper-sensor readings with keep-alive poll 2026-06-10 14:06:31 +10:00
raster.go Print image and caption together in one job 2026-07-20 12:34:23 +10:00
README.md Print image and caption together in one job 2026-07-20 12:34:23 +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 or images 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"
curl --data-binary @photo.png         http://127.0.0.1:8472/print

Images

POST an image (PNG, JPEG, GIF, BMP, WebP) instead of text and it is scaled to the 384-dot head width (aspect ratio preserved) and dithered to 1-bit with FloydSteinberg before printing, so photos and gradients come out readable. The body is treated as an image when the Content-Type is image/* or the bytes sniff as one, so plain curl --data-binary @photo.png works with no extra flags. Transparency is flattened onto white. rotate applies; size and wrap are text-only.

To print an image with a caption below it, send a multipart form — the image file field prints above the text field (either may be sent alone):

curl -F 'image=@photo.png' -F 'text=hello from the printer' http://127.0.0.1:8472/print

HTTP API

  • POST /print — body is the text or image to print, or a multipart form with an image file field and/or a text field (image prints above the text). Query params:
    • size=N font size in points (default 24; text only)
    • rotate=false disable the default 180° rotation
    • wrap=false preserve lines and spaces verbatim (text only, 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. Attach, paste, or drop an image to print it (dithered, scaled to fit) — any text in the box prints below it as a caption; the preview shows the exact 1-bit result.
  • 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":…,"battery":N|null,"batteryAge":S,"paper":N|null,…}: whether the printer is reachable (a short BLE scan for its advertisement, cached for 10 s), its last battery reading (battery, raw byte the M02 reports as an approximate percentage; null until first read), and its last paper-sensor reading (paper raw byte, plus paperHex, paperLabel, and paperAge; null until first read). Both readings are cached — refreshed by prints (battery only) and keep-alive polls. The web form shows the dot, a 🔋 indicator, and a "⚠️ out of paper" badge when the sensor reads 0x88.
  • GET /paper — does a live BLE query of the paper sensor and returns {"raw":N,"hex":"0xNN","label":"…","present":true|false|null}. The M02 documents 0x89 = paper present, 0x88 = no paper; the sensor detects media under the head (not how much roll is left), so other bytes are reported verbatim. Use this to characterise the sensor: change the roll, re-request, compare raw.
  • GET /healthz — returns ok (the server is alive; says nothing about the printer).

The battery level is read from the printer on every print (the job footer already asks for it) and by the keep-alive poll (see below). The reported value is approximate — the M02's battery byte (observed 0x290x3A) is treated as a rough percentage.

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)
  • -image FILE print an image once and exit (with -text the text prints below it; combine with -preview to render instead)
  • -preview FILE.png render -text or -image to a PNG and exit, without printing
  • -keepalive DUR poll the printer this often to read battery and discourage auto-power-off (default 90s; 0 disables). Best-effort: the M02 has no documented sleep-disable command, so this just relies on periodic BLE activity resetting its idle timer. Intervals below 20s are clamped (rapid reconnects destabilise the BLE stack).

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.