Print labels on a Phomemo D30 thermal label printer from Windows, over its Bluetooth SPP (COM port) connection — from the command line, or from any website via a small local print server.
The print protocol was reverse-engineered by the
phomemo_d30 project (by
sniffing the "Print Master" Android app's Bluetooth traffic) and is
reimplemented here in pure Python — no ImageMagick/Wand dependency, just
pyserial, Pillow, and Flask.
- Install Python — download and run the installer from python.org/downloads. On the first install screen, tick "Add python.exe to PATH" before clicking Install.
- Download this project — on this page, click the green Code button, then Download ZIP, and extract it somewhere handy (e.g. your Desktop).
- Pair the D30 in Windows Bluetooth settings, if you haven't already (Settings > Bluetooth & devices > Add device).
- Run setup — double-click
setup.batin the extracted folder. This installs everything needed and only has to be done once. - Start printing — double-click
start_server.bat. A window opens showing the server running, and your browser opens a page where you can type a label and click Print.
Leave that window open while printing; closing it stops the server. If a label doesn't print, see Troubleshooting below — the most common issue is Windows not having actually connected the Bluetooth link yet (shows as "Driver is unavailable" in Bluetooth settings).
The rest of this README covers the command-line tools and the HTTP API in more detail, for anyone who wants to script it or wire it into their own website.
-
Pair the D30 in Windows Bluetooth settings, if you haven't already.
-
Find its COM port: Settings > Bluetooth & devices > Devices > [your D30] > More Bluetooth options > COM Ports tab, or check Device Manager > Ports (COM & LPT). Use the Outgoing port. (You generally won't need this — see auto-detection below.)
-
Install dependencies (Python 3.9+):
python -m venv venv venv\Scripts\pip install -r requirements.txt
--port is optional — if you omit it, the script scans Windows' listed
COM ports for one that accepts data and uses it automatically:
venv\Scripts\python print_text.py "Hello World!"Or specify it directly if you already know it (faster, and required if more than one port responds):
venv\Scripts\python print_text.py "Hello World!" --port COM4Multi-line label (use \n literally, it's converted to a real line break):
venv\Scripts\python print_text.py "First line\nSecond line" --port COM5Custom font:
venv\Scripts\python print_text.py --font C:\Windows\Fonts\comic.ttf "Hi" --port COM5Smaller label stock — the printhead is a fixed 96px/12mm wide no matter
what, so only --length (the feed-direction size, in mm) needs setting;
default is 40mm (Phomemo's standard label):
venv\Scripts\python print_text.py --length 30 "Hi" --port COM4Multiple copies — for die-cut labels on a continuous roll there's no cut
command, so a short pause (--pause-ms, default 250) runs between copies
to let the printer settle and advance to the next label:
venv\Scripts\python print_text.py --count 10 "Hi" --port COM4print_batch_label.py prints a ready-made two-line label — Batch <n> on
one line, Best Before: <date> on the other — sized for 14x30mm stock by
default:
venv\Scripts\python print_batch_label.py --batch 123456 --best-before 04-09-26Add --product to print a centered, underlined product line above the batch table (no "Product:" label):
venv\Scripts\python print_batch_label.py --batch 123456 --best-before 04-09-26 --product "Olive Oil 500ml"--best-before is printed exactly as given (no date parsing/validation),
so pass it already formatted the way you want it on the label. --length,
--font, --count, and --pause-ms all work the same as print_text.py
if you need a different
label size.
server.py runs a small local HTTP server with a single print endpoint
and a docs/usage page. Double-click start_server.bat, or run it directly:
venv\Scripts\python server.pyThen open http://localhost:8787/ for the full docs — parameters,
examples, and a live test form. In short: GET, POST, or PUT to
/api/print with mode (text or batch) plus that mode's fields
(text, or batch + best_before, plus optional centered product), sent as a JSON body or as query
string / form fields, e.g.:
fetch("http://localhost:8787/api/print", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode: "batch", batch: "123456", best_before: "04-09-26", product: "Olive Oil 500ml" }),
})It binds to 127.0.0.1 by default (this machine only, matches "runs on
localhost"). There's no authentication, so only pass --host 0.0.0.0 if
you understand anyone on the network could then trigger a print.
COM-port auto-detection happens per request, same as the CLI scripts — a
request fails with a clear JSON error (not a hang) if nothing responds or
more than one port does.
- Script just hangs / freezes:
print_text.pynow sets a 5s write timeout, so it should fail with a clear error instead. If it still hangs, rundebug_port.py(below) for a step-by-step trace. - "Driver is unavailable" next to D30 in Windows Bluetooth devices: this means Windows paired the device but never bonded it as a serial (SPP) device, so the COM port exists but nothing actually connects when you open it — this is very likely the cause of a freeze/timeout. Try removing the device and re-pairing; make sure it shows Connected, not just Paired, before printing.
- Two COM ports show up (e.g. COM3 & COM4): Windows creates an "Outgoing" and an "Incoming" port for each Bluetooth SPP device — check Device Manager or Bluetooth settings > More Bluetooth options > COM Ports tab to see which is which, and use the Outgoing one.
- Garbled or blank print: the D30 sometimes needs the port closed and reopened between jobs — the script does this automatically per run.
- Label dots are physically 96px across; the script handles resizing and rotation, so any text length works as long as it fits after auto-shrinking the font.
A standalone connectivity probe — opens a COM port, sends the printer's init sequence one packet at a time, and logs (with timestamps) whether each write succeeded and whether anything came back:
venv\Scripts\python debug_port.py --port COM3Things to look for in the output:
- A write times out ("WRITE TIMED OUT") → the OS accepted the port handle but the Bluetooth link itself isn't actually connected. This is a Windows/pairing problem, not the script — see the "Driver is unavailable" note above.
- Everything sends successfully, nothing ever comes back → expected. The D30 protocol is one-way (fire-and-forget, no acknowledgement from the printer), so no reply doesn't mean anything is wrong — it confirms the COM port and pairing work, but not that a label physically printed.
- Data comes back → unexpected for this protocol; worth inspecting the hex the printer sent.
Run it against both COM ports if you're not sure which is outgoing.
Faster than debug_port.py when you just want an answer, not a trace.
Brute-forces a numeric range (COM1..COM8 by default) or, with --listed,
probes only the ports Windows currently enumerates:
venv\Scripts\python find_port.py
venv\Scripts\python find_port.py --range 1-16
venv\Scripts\python find_port.py --listedSame caveat as everywhere else: the D30 never replies, so "accepted data"
means the OS took the write without timing out, not that it's confirmed to
be a D30 specifically. If multiple ports accept data, that's normal (an
Outgoing + Incoming port pair per Bluetooth device) — pick the Outgoing
one, or just try each with print_text.py.
d30/protocol.py— raw byte protocol (init sequence, raster header, bit packing).d30/image.py— renders text into the 1-bit image the printer expects, at whatever length (--length, in mm) the label needs.d30/printer.py—D30Printer, the serial connection wrapper.d30/discovery.py— COM port auto-detection.d30/cli.py—--portresolution for the CLI scripts (prints + exits on failure).d30/jobs.py— shared "render + resolve port + print N copies" logic, used by both the CLI scripts and the API server.d30/docs_page.py— the HTML for the API's docs page.print_text.py— CLI entry point for free-form text labels.print_batch_label.py— CLI entry point for batch/best-before labels.server.py— local HTTP API + docs page, for printing from a website.find_port.py,debug_port.py— connectivity diagnostics.setup.bat,start_server.bat— double-click setup/run for Windows users who don't want a terminal.
- Print arbitrary images (barcodes, QR codes, logos) —
image_to_rows()already accepts any 96px-wide 1-bit image, so this is mostly a newprint_image.pyCLI (and animagemode on the API), no protocol work needed. - Package as an installable CLI (
pip install -e .). - Auth on the API server, if it ever needs to listen on more than
127.0.0.1.