Label Printing for Device Provisioning¶
How to print a physical label carrying a QR code plus the device name/serial
(e.g. rpi-lite-prod-1883c404a544) as a step in the provisioning run described
in runbooks/provisioning.md.
Everything below is researched against vendor pages, vendor CDN data files, and open-source driver source. Claims that could not be traced to a first-party or source-code artefact are marked UNVERIFIED.
TL;DR¶
- "Print Master" is Phomemo's app. Developer is
PHOMEMO PTE. LTD., App Store id1467113436. So the printer is a Phomemo (or Phomemo-OEM) BLE thermal label printer — D30/D35/Q30 class (household) or M110/M120/M220 class (business). - Protocol is Epson ESC/POS-derived raster, not TSPL/CPCL. The payload is a
GS v 0raster bit image wrapped in Phomemo-specific0x1f 0x11 ...vendor commands. Reverse-engineered, not vendor-published. - Native resolution is 203 dpi (8 dots/mm) on both the D30 and the M110 — verified three independent ways (see Resolution).
- A Mac can drive an M110 without the iOS app — Phomemo ships an official macOS
driver for the business line. A Mac probably cannot drive a D30 without the app
via any vendor path; the only route is a BLE GATT write from Python/
bleakusing community-reverse-engineered UUIDs. - No vendor SDK exists for either family. Nothing on phomemo.com offers an API, SDK, or command manual.
- Model is confirmed: Phomemo M110 (FCC ID
2ASRB-M110S, read off the unit's back label 2026-08-21). This puts us on the business-line branch throughout: official macOS driver, CUPS queue,lpfrom a justfile recipe. The D30/BLE reverse-engineering branch below is retained for reference only — it does not apply to us.
Model: resolved¶
Confirmed 2026-08-21 from the printer's back label:
| Field | Value |
|---|---|
| Model | M110 |
| Marking | "Phomemo — Smart label printer" |
| FCC ID | 2ASRB-M110S |
| Input | 5V 2A |
Consequences, all of which collapse forks elsewhere in this document:
- Mac path is the official vendor driver, not BLE reverse-engineering. See (a) M110 family.
- Default media is 40 × 30 mm = 320 × 240 px at 8 dots/mm.
- A
box_size=3QR fits comfortably — 15.4 mm for a 43-char URL at ECC Q, leaving ~24 mm of label width for the device-name text. The D30's 12 mm constraint that forcedbox_size=2does not apply. - USB is available on this family, so the BLE route is not the only fallback.
Working setup (verified 2026-08-21)¶
Printing works end to end from the Mac over USB. Vendor driver + CUPS queue +
lp, no BLE and no iOS app. Confirmed by a physical label whose QR scans.
just docs label-print rpi-lite-prod-1883c404a544
# equivalently:
lp -d M110 -o media=w40h20 labels/rpi-lite-prod-1883c404a544.pdf
Printing for a device that is not yet in config/devices.yml¶
The generator reads the fleet config, so a device whose registration PR has not merged yet is not found. Point it at the other config instead of waiting:
git show <branch>:config/devices.yml > /tmp/devices.yml
python3 scripts/generate_device_labels.py \
--device rpi-lite-prod-<serial> --devices-config /tmp/devices.yml
lp -d M110 -o media=w40h20 labels/rpi-lite-prod-<serial>.pdf
This is a stopgap for labelling hardware that is already deployed. The QR encodes a plain hostname URL, so the label stays correct once the PR lands -- nothing in it depends on the config the label was generated from.
One-time setup¶
- Install Phomemo's macOS driver from
https://phomemo.com/pages/download-center-m110 (
QY_Printer_Driver_Mac.zip). - Connect the printer by USB-C data cable. CUPS then offers
usb:///M110S?serial=.... Bluetooth is not usable: Labelife drives the printer over BLE from inside its own sandbox and never exposes it to CUPS, and CUPS'bluetoothbackend needs classic SPP pairing, which the M110 does not offer. just docs label-ppd, then run the printedlpadminline (needs sudo).
Three findings that cost time¶
The vendor PPD does not parse. Twelve zh_CN translation lines in
M110SA.ppd use a fullwidth colon : (U+FF1A) instead of ASCII :, so
cupstestppd reports "Unable to open PPD file - Missing value string" on the
stock file. scripts/make_m110_ppd.py repairs it.
No Phomemo PPD declares 40x20 mm. M110, M110S and M110-Q759 offer only
48 mm receipt widths; M110SA offers 40x30 but not 40x20. Note the printer
enumerates as M110S over USB while the only PPD with 40 mm media is M110SA —
choose the PPD on media, not on the name matching the device URI or the FCC ID.
The print origin is 4 mm left of the label. The head is 48 mm wide and 40 mm
stock sits centred under it, so a raster aligned to the head origin loses its
leftmost 4 mm — which clips the QR. generate_device_labels.py compensates with
OFFSET_X_DOTS = 32 (32 dots / 8 dots per mm), and that offset also reduces the
usable width to 36 mm, so content is laid out in the reduced box rather than
merely translated. Re-measure on another unit with just docs label-calibrate:
the target prints numbered 5 mm ticks, and the first tick that falls off the
label edge gives the offset directly.
Label design as built¶
| Property | Value |
|---|---|
| Media | 40 x 20 mm, 320 x 160 dots at 8 dots/mm |
| QR payload | https://<device-id>.tagai.uk (43 chars) |
| QR | ECC Q, version 4, 33 modules, box_size=3, border=4 -> 15.4 mm |
| Text | serial at ~4 mm cap height, then environment prefix, then location |
| Output | PDF (CCITT G4, page exactly 40.00 x 20.00 mm), plus PNG and SVG |
The serial is the large line because it is what a human reads off the device; the full 26-character device id would have to shrink to an unreadable size to fit beside the QR, and it adds only the environment prefix, which is carried on its own smaller line.
1. What "Print Master" drives¶
App Store listing: https://apps.apple.com/us/app/print-master/id1467113436
Developer / seller: PHOMEMO PTE. LTD. (confirmed via the iTunes Lookup API,
https://itunes.apple.com/lookup?id=1467113436, fields sellerName/artistName).
Minimum iOS 14.0.
Phomemo's own driver-portal data file lists the exact model set Print Master
covers. From
https://cdn.shopify.com/s/files/1/0180/9294/9568/files/allDownload_drivers_v7.json
(the JSON that backs https://phomemo.com/pages/drivers), the appType===1
group — i.e. the Print Master group — is verbatim:
M110, M120, M200, M220, M221, P12, P12 PRO, P3200, D30, D35, Q30, Q30S,
D50, P3100, P3100D, M960, E50 PRO
The same file gives Phomemo's own description of the app:
Print Master is an intelligent label printing and editing software. Connecting app with the printer via Bluetooth of the mobile phone […] 【Edit】Freely insert and edit text, one-dimensional-code, two-dimensional code, forms, logos, pictures, date and time, etc.; 【Batch Printing】Support Excel data table import, achieve batch printing; 【Scan Printing】By scanning […] 【Number】Numbers can be set, print in sequence for both text and barcodes;
The App Store description independently confirms the batch feature: "For apparel, food, jewelry, and retail businesses - import data from Excel to print multiple labels in seconds."
Ruled out¶
- Niimbot — different vendor, different app (
NIIMBOT), different (non-ESC/POS) proprietary protocol. Not driven by Print Master. - Marklife — ships its own iOS app literally named
Marklife(https://apps.apple.com/us/app/marklife/id1540535142, developer Shenzhen Yinxiaoqian Technology Co. Ltd.), not Print Master. - Generic ESC/POS receipt printers — no vendor relationship to Print Master.
Note there is a different App Store app called "Label Maker: Print Master"
(id 6529532003, developer Ayesha Aslam) — unrelated to Phomemo. If the user's
app icon/developer doesn't say Phomemo, this whole document's premise changes.
2. Protocol and automation surface¶
Is there a vendor SDK or API?¶
No. phomemo.com's developer-facing surface is exactly one page, https://phomemo.com/pages/drivers, and it offers only printer drivers and end-user apps — no SDK, no API, no protocol/command manual. There is no published ESC/POS, TSPL, or CPCL manual for any Phomemo model. Everything in the next section comes from packet sniffing by third parties.
The command set (community reverse-engineered)¶
The authoritative write-up is
vivier/phomemo-tools README §4–5,
which states plainly: "All the information here has been reverse-engineered
sniffing the bluetooth packets emitted by the Android application."
It is Epson ESC/POS-derived, wrapped in Phomemo vendor extensions:
| Bytes | Meaning | Source |
|---|---|---|
1b 40 |
ESC @ — initialize printer |
phomemo-tools README §4.1 |
1d 76 30 <mode> <bytesPerLine:u16le> <lines:u16le> |
GS v 0 — print raster bit image |
phomemo-tools README §4.2 |
1b 64 <n> |
ESC d — print and feed n lines |
phomemo-tools README §4.3 |
1f 11 xx |
Phomemo vendor commands (energy/status/media) | phomemo-tools README §4.1/§4.3 |
1b 4e 0d <speed> |
Print speed, 0x01 slow … 0x05 fast (M110 family) | phomemo-tools README §5.1 |
1b 4e 04 <density> |
Print density, 0x01–0x0f (M110 family) | phomemo-tools README §5.1 |
1f 11 0a\|0b\|26 |
Media type: Label-with-gaps / Continuous / Label-with-marks (M110 family) | phomemo-tools README §5.1 |
Max 255 image lines per GS v 0 block; longer images are split into successive
blocks (README §4.2,
implemented in image_helper.split_image).
Do D30 and M110 differ in protocol? Yes, but only at the edges. Both use the
same GS v 0 raster core. Differences:
- The M110 family accepts speed/density/media-type preamble commands the D30 does not (README §5.1).
- The D30 needs a distinct init/wake sequence.
polskafan/phomemo_d30sends seven vendor packets (1f1138,1f11121f1113,1f1109,1f1111,1f1119,1f1107,1f110a1f110202) captured from Print Master before any image data — commented in-source as "printer initialization sniffed from Android app 'Print Master'". - The D30 prints rotated 90°: artwork for a 40×12 mm label is composed as a
320×96 landscape image and rotated 270° before transmission
(
print_text.py). - CUPS filters are separate per family:
rastertopd30.pyvsrastertopm110.py.
Open-source drivers¶
| Repo | Lang | Stars | Last push | Models claimed | Transport |
|---|---|---|---|---|---|
| vivier/phomemo-tools | Python (CUPS) | 453 | 2026-07-16 | M02/M02Pro/M02S/T02, M110/M120/M220, D30, M04, M421 | BlueZ RFCOMM or USB |
| polskafan/phomemo_d30 | Python | 74 | 2023-01-01 | D30 | RFCOMM serial (/dev/rfcomm1) |
| odensc/phomemo-d30-web-bluetooth | JS | 31 | 2026-05-01 | D30 | BLE GATT (Web Bluetooth) |
| theacodes/phomemo_m02s | Python | 66 | 2023-11-20 | M02S | Bluetooth serial |
| Knightro63/phomemo | Dart | 27 | 2025-07-31 | Phomemo generally | BLE |
| transcriptionstream/phomymo | JS | 144 | 2026-05-17 | Phomemo generally | Web Bluetooth |
Baselines for comparison (section 5): pklaus/brother_ql (Python, 702★, last push 2026-01-09, Brother QL series), AndBondStyle/niimprint (Python, 458★, 2026-03-15, Niimbot D11/B21/B1), MultiMote/niimblue (TypeScript, 728★, 2026-08-10, Niimbot web client).
BLE required? Vendor cloud account?¶
- BLE: for the D30 from iOS/macOS, effectively yes. Linux tooling uses classic
Bluetooth RFCOMM/SPP, but iOS apps cannot open an SPP socket to a non-MFi
accessory, so Print Master must be speaking BLE GATT — and the Web Bluetooth
proof-of-concept confirms a working BLE path exists. The GATT handles are, from
index.js: - service
0000ff00-0000-1000-8000-00805f9b34fb - write characteristic
0000ff02-0000-1000-8000-00805f9b34fb - written in ~128-byte chunks (
src/printer.js:PACKET_SIZE_BYTES = 128)
These UUIDs are community-reverse-engineered, from that repo, not vendor-published. - Vendor cloud account: not required for printing. Print Master's cloud template library is a convenience feature. UNVERIFIED whether the app forces an account for Excel import specifically.
3. Can a Mac drive this printer directly?¶
Ranked, with honest confidence levels.
(a) M110 family — official Phomemo macOS driver — PROVEN to exist¶
Phomemo's driver portal JS classifies models into groups and hands each group a driver bundle. The business group is verbatim:
const businessDrivers = ['M100','M110','M120','M200','M221','M220','M250',
'M260','M100','M108','M150','E50 PRO','M160','M420','M421']
const businessMacDriver = { url: "https://oss.qu-in.ltd/Labelife/QY_Printer_Driver_Mac.zip" }
plus a Mac App Store desktop app, Labelife (https://apps.apple.com/us/app/labelife/id1560922539?mt=12), and Linux/Windows bundles at the same host. The page states the driver targets macOS 10.14 (Mojave) and above.
If this installs a CUPS queue, the whole pipeline collapses to
lp -d M110 label.png from a justfile recipe. UNVERIFIED: whether
QY_Printer_Driver_Mac.zip registers a CUPS/AirPrint queue versus only working
inside Labelife, and whether it supports Bluetooth or USB-only. Downloading and
installing it is the single highest-value test if the printer turns out to be an
M110.
(b) D30 family — no vendor desktop driver — Python + bleak is the only path¶
The D30 appears in neither businessDrivers nor portableDrivers in that same
JS; it is classified under "Label Machine" (household) in
allDownload_drivers_v7.json, and the page's own copy for that group reads:
This printer does not require a driver. Go to download APP.
So: no macOS driver, no CUPS PPD from the vendor, no Labelife support.
What does work on macOS is bleak, whose README
states "OS X/macOS support via Core Bluetooth API, from at least OS X version
10.15." Combining bleak with the FF00/FF02 GATT handles above and the
polskafan/phomemo_d30 byte sequences gives a pure-Python macOS printing path.
Status: UNVERIFIED end-to-end. No repository reviewed demonstrates bleak-on-macOS → Phomemo D30 working. Every Python D30 implementation found uses Linux RFCOMM; every BLE implementation found is JavaScript/Web Bluetooth. The pieces are all individually proven; the combination is not. Budget half a day.
Also note: macOS can expose a paired classic-Bluetooth SPP device as
/dev/cu.<name>-SerialPort, which would let polskafan/phomemo_d30 run nearly
unmodified via pyserial. UNVERIFIED whether the D30 advertises an SPP profile
that macOS will bind — the Linux tooling proves the profile exists on the device,
but macOS's SPP handling for non-MFi accessories is inconsistent.
(c) Generate PNG on the Mac, print from the iPhone — the fallback that definitely works¶
Covered in section 4's fallback lane. Zero risk, ~30 s of human time per label.
Five-minute verification¶
Run this on the Mac with the printer powered on and not connected to the phone. It settles model, transport, and GATT surface in one shot.
python3 -m venv /tmp/blevenv && /tmp/blevenv/bin/pip install bleak
/tmp/blevenv/bin/python - <<'PY'
import asyncio
from bleak import BleakScanner, BleakClient
async def main():
devs = await BleakScanner.discover(timeout=10.0)
for d in devs:
print(d.address, "|", d.name)
target = next((d for d in devs if d.name and
("Mr.in" in d.name or d.name.startswith(("D30","D35","M110","M120","Q30")))), None)
if not target:
print("No Phomemo-looking device. Check it is on and not paired to the phone.")
return
print("\nConnecting to", target.name, target.address)
async with BleakClient(target.address) as c:
for s in c.services:
print("service", s.uuid)
for ch in s.characteristics:
print(" char", ch.uuid, ch.properties)
asyncio.run(main())
PY
Expected pass condition: the advertised name reveals the model, and a service
0000ff00-... containing a writable characteristic 0000ff02-... appears. If you
see those, path (a)/(b) is open and the rest is byte-formatting work. If the device
does not appear in a BLE scan at all, it is classic-Bluetooth-only and only
macOS SPP or the iOS fallback remain.
4. Recommended pipeline¶
QR payload — a device URL¶
The decision is already made: the QR encodes a URL to the device page. What remains is which URL and how short.
Trade-offs, briefly:
- Bare serial (
1883c404a544) — shortest QR, but a phone camera scanning it just shows a hex string. No affordance. - Full device dashboard URL (
https://rpi-lite-prod-1883c404a544.tagai.uk) — 43 chars, self-describing, lands behind Cloudflare Access so it is already authenticated-by-default. Encodes the environment and the serial in the URL itself, so the label stays readable even if the QR is destroyed. - Short redirect (
https://tagai.uk/d/1883c404a544) — 31 chars, one QR version smaller, and decouples the label from the hostname scheme (survives a rename or an environment promotion, dev → prod, without reprinting). Costs one Cloudflare Worker/Redirect Rule. - Signed token — do not. Labels are physical and permanent; tokens expire and cannot be rotated once printed. Keep authorisation at Cloudflare Access, where it already lives, and keep the QR a plain identifier.
Recommendation: the short redirect form, falling back to the full device URL if you do not want to own a redirect rule. Print the device name in human-readable text underneath regardless — a scuffed QR must not make the label useless.
Error correction: use M (~15%) as the default. Thermal labels on enclosures scuff, but the failure mode is uniform fading rather than localized occlusion, which ECC handles poorly regardless of level. Going to Q (~25%) costs nothing for our URL lengths (see the table — same QR version for 31 and 43 chars), so use Q: free redundancy. Do not use H; it pushes to a larger version for longer URLs and shrinks the module size at fixed label width.
The four levels and their stated recovery rates are documented in
python-qrcode's README:
ERROR_CORRECT_L ~7%, ERROR_CORRECT_M (default) ~15%, ERROR_CORRECT_Q ~25%,
ERROR_CORRECT_H ~30%.
Resolution and pixel geometry¶
203 dpi (8 dots/mm) for both D30 and M110. Verified three ways:
- Vendor: "The M110 uses 203 dpi resolution" — Phomemo M110 setup guide.
- Driver:
*Resolution - 8 0 0 0 203dpiappears in bothphomemo-d30.drvandphomemo-m110.drv. - Arithmetic cross-check: the sniffed D30 init header
1f1124001b401d7630000c004001decodes to a 96 × 320 px raster for the 12 × 40 mm label (polskafan README), and 12 mm × 8 dots/mm = 96, 40 mm × 8 dots/mm = 320. Exact match.
Note phomemo-tools' M02 README says "80 pt/cm or 203,2 dpi" — 203.2 dpi is exactly 8 dots/mm, and 203 dpi is the marketing round-off. Use 8 dots/mm in code; it gives integer pixel dimensions for every whole-millimetre label.
Canvas sizes at 8 dots/mm:
| Label | Pixels | Family |
|---|---|---|
| 12 × 40 mm | 96 × 320 | D30 (default; #media "w40h12" in phomemo-d30.drv) |
| 14 × 30 mm | 112 × 240 | D30 (#media "w30h14") |
| 14 × 40 mm | 112 × 320 | D30 (Phomemo sells this roll for D30/D35/Q30/Q30S) |
| 40 × 30 mm | 320 × 240 | M110 (default — *MediaSize "w40h30") |
| 30 × 20 mm | 240 × 160 | M110 |
| 50 × 30 mm | 400 × 240 | M110 |
The M110 driver declares 22 media sizes from 20 × 10 mm to 70 × 80 mm; the D30 driver declares exactly two. That constraint alone should drive the model choice if the user is still buying.
Minimum QR size in mm¶
Computed with qrcode 8.2 locally, border=4 (the spec minimum quiet zone),
at 8 dots/mm. box_size is the module size in printer dots — so box_size is
the dots-per-module knob, and at 203 dpi one dot is 0.125 mm.
| URL length | ECC | QR version | Modules | box_size=2 | box_size=3 | box_size=4 |
|---|---|---|---|---|---|---|
| ~40 chars | M | 3 | 29 | 74 px / 9.2 mm | 111 px / 13.9 mm | 148 px / 18.5 mm |
| ~40 chars | Q | 4 | 33 | 82 px / 10.2 mm | 123 px / 15.4 mm | 164 px / 20.5 mm |
| ~60 chars | M | 4 | 33 | 82 px / 10.2 mm | 123 px / 15.4 mm | 164 px / 20.5 mm |
| ~60 chars | Q | 5 | 37 | 90 px / 11.2 mm | 135 px / 16.9 mm | 180 px / 22.5 mm |
(dimensions include the 4-module quiet zone on each side)
Real URLs from this fleet:
| URL | Chars | ECC Q version | box_size=3 |
|---|---|---|---|
https://tagai.uk/d/1883c404a544 |
31 | v3, 29 modules | 111 px / 13.9 mm |
https://rpi-lite-prod-1883c404a544.tagai.uk |
43 | v4, 33 modules | 123 px / 15.4 mm |
https://rpi-prod-1883c404a12f-nodered.tagai.uk |
46 | v4, 33 modules | 123 px / 15.4 mm |
box_size=2 (2 dots ≈ 0.25 mm per module) is the practical floor for thermal
printing — below that, thermal dot bleed merges adjacent modules. box_size=3
(0.375 mm/module) is the safe default.
Practical consequence: on a D30 12 mm-wide label, a box_size=3 QR is
13.9–15.4 mm — it does not fit across the 12 mm width. You must either drop to
box_size=2 (9.2–10.2 mm, fits with ~1 mm margin), shorten the URL, or move to a
14 mm roll. On an M110 40 × 30 mm label, a box_size=3 QR at 15.4 mm leaves
~24 mm of width for the device-name text — comfortable. This is the strongest
argument for the M110 family.
Generating the artwork (Python)¶
import qrcode
from qrcode.constants import ERROR_CORRECT_Q
from PIL import Image, ImageDraw, ImageFont
DOTS_PER_MM = 8 # 203.2 dpi
def label(device: str, w_mm: int, h_mm: int, box_size: int = 3) -> Image.Image:
url = f"https://tagai.uk/d/{device.rsplit('-', 1)[-1]}"
qr = qrcode.QRCode(
version=None, # auto-size via make(fit=True)
error_correction=ERROR_CORRECT_Q,
box_size=box_size, # printer dots per QR module
border=4, # spec minimum quiet zone
)
qr.add_data(url)
qr.make(fit=True)
qr_img = qr.make_image(fill_color="black", back_color="white").convert("1")
canvas = Image.new("1", (w_mm * DOTS_PER_MM, h_mm * DOTS_PER_MM), 1)
canvas.paste(qr_img, (4, (canvas.height - qr_img.height) // 2))
draw = ImageDraw.Draw(canvas)
font = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial Bold.ttf", 22)
draw.text((qr_img.width + 12, canvas.height // 2 - 12), device, font=font, fill=0)
return canvas # already mode "1" — no dithering applied
API references, all from python-qrcode's README:
qrcode.QRCode(version=..., error_correction=..., box_size=..., border=...)— "Theversionparameter is an integer from 1 to 40 […] Set toNoneand use thefitparameter when making the code to determine this automatically."qr.make(fit=True)— auto-selects the smallest version that holds the data.box_size— "controls how many pixels each 'box' of the QR code is."border— "controls how many boxes thick the border should be (the default is 4, which is the minimum according to the specs)."qr.make_image(fill_color="black", back_color="white").
Monochrome and dithering¶
The printer is 1 bit per pixel, no greyscale. Both CUPS filters assert this:
rastertopd30.py raises ValueError('Invalid color space, only monocolor supported')
unless cupsColorSpace == 0 and cupsNumColors == 1.
Rules:
- Compose directly in Pillow mode
"1". Do not render greyscale and convert at the end — Pillow's defaultconvert("1")applies Floyd–Steinberg dithering, which turns QR module edges and text antialiasing into speckle and can break scanning. - If you must convert from another mode, use
img.convert("1", dither=Image.Dither.NONE)and control the threshold explicitly. The community pipeline (image_helper.image_to_bits) uses a hard threshold of 127, not dithering. - Never dither the QR itself. Dithering is only ever appropriate for photographic content, which a provisioning label does not contain.
- Bit packing is MSB-first, 8 pixels per byte,
(width + 7) // 8bytes per row (bytes_per_lineinrastertopd30.py). Note the community D30 path inverts before packing (PIL.ImageOps.invert(...).convert("1")) — 1 means "burn". Verify polarity on the first test print rather than reasoning about it.
Fallback lane: generate on the Mac, print from the iPhone¶
Where a direct Mac path is unproven, this always works and needs no new hardware:
justgenerates the PNG at exact native pixel dimensions (above).- AirDrop it to the iPhone.
- In Print Master, insert it as a picture. The vendor's own feature list confirms picture insertion: "Freely insert and edit text, one-dimensional-code, two-dimensional code, forms, logos, pictures, date and time" (Phomemo driver-portal JSON).
For a whole batch of devices, use Print Master's Excel import instead of images:
export a CSV/XLSX of device names from config/devices.yml, and let the app
generate the QR from a URL column. Both the App Store description and the vendor
JSON document Excel-driven batch printing, and the vendor JSON separately documents
a 【Number】 serialized-increment feature. What is UNVERIFIED is whether the
Excel-import flow can bind a spreadsheet column to a QR code element (as opposed
to a text element) — that needs a hands-on check in the app, and it is the
difference between "batch-print 6 labels in one action" and "do it six times".
Apple Shortcuts / URL scheme / Share Sheet: no evidence found either way. Phomemo publishes no URL scheme, no Shortcuts actions, and no share-extension documentation, and the App Store listing mentions none. Treat automation of the iOS app as UNVERIFIED and probably unavailable — do not build a plan around it. The empirically cheap check is to open Shortcuts on the iPhone and search the app list for "Print Master"; if it exposes no actions, it is settled.
5. Print Master + AirDrop vs. a scriptable printer¶
| Stay on Print Master + AirDrop a generated PNG | Fix the Mac→Phomemo path (bleak/CUPS) | Switch to Brother QL + brother_ql |
|
|---|---|---|---|
| Hardware cost | €0 | €0 | ~€100–150 for a QL-800/QL-820NWB + tapes |
| Setup effort | ~1 h (write the PNG generator) | 0.5–1 day if D30 (unproven BLE work); ~1 h if M110 (install vendor driver, add a CUPS queue) | ~2 h (pip install brother_ql, USB, done) |
| Per-label friction | ~30 s of human time: AirDrop, open app, insert image, print | ~0 s: one just recipe in the provisioning run |
~0 s: one just recipe |
| Batch of 6 devices | ~3 min, or use Excel import (UNVERIFIED for QR columns) | scripted loop | scripted loop |
| Risk | none — this is today's working path | D30: real chance the BLE path costs more than a day. M110: low | none technically; a purchase decision |
| Fits provisioning runbook? | No — breaks the one-command flow with a manual detour | Yes | Yes |
| Ongoing maintenance | vendor app updates can change the UI | community-reverse-engineered protocol; firmware update could break it | brother_ql is 702★ and actively maintained; Brother publishes a raster command reference |
Recommendation, staged:
- Now: build the Python PNG generator (section 4). It is required by every
path and is the only work with zero wasted effort. Ship it as a
justrecipe (label) that writesconfig/<device>/label.png. - Then: run the five-minute BLE verification. If the printer is an M110, install the vendor macOS driver and try a CUPS queue — that likely closes the whole problem for an hour of work.
- If it is a D30 and the BLE path stalls past half a day: stop. Either keep AirDrop as the documented manual step, or buy a Brother QL. At fleet scale the QL pays for itself; at 6 devices it does not, and AirDrop is fine.
The one thing not worth doing is investing in automating the iOS app.
Sources¶
Vendor / first-party
- Print Master on the App Store — https://apps.apple.com/us/app/print-master/id1467113436
- iTunes Lookup API record (developer =
PHOMEMO PTE. LTD.) — https://itunes.apple.com/lookup?id=1467113436 - Phomemo driver portal — https://phomemo.com/pages/drivers
- Phomemo driver-portal data file (model↔app↔driver mapping, app descriptions) — https://cdn.shopify.com/s/files/1/0180/9294/9568/files/allDownload_drivers_v7.json
- Phomemo M110 setup guide (203 dpi) — https://phomemo.com/blogs/guides/m110-setup-guide-from-unboxing-to-printing-in-5-minutes
- Phomemo M110 download centre — https://phomemo.com/pages/download-center-m110
- Phomemo D30 download centre — https://phomemo.com/pages/download-center-d30
- Labelife (Phomemo desktop app, macOS) — https://apps.apple.com/us/app/labelife/id1560922539
- Marklife iOS app (ruled out) — https://apps.apple.com/us/app/marklife/id1540535142
Open-source drivers (community-reverse-engineered — no vendor protocol spec exists)
- vivier/phomemo-tools — https://github.com/vivier/phomemo-tools
- protocol write-up: README.md §4–5
cups/drv/phomemo-d30.drvcups/drv/phomemo-m110.drvcups/filter/rastertopd30.pycups/backend/phomemo.py- polskafan/phomemo_d30 (D30 init sequence, 320×96 geometry) — https://github.com/polskafan/phomemo_d30
- odensc/phomemo-d30-web-bluetooth (BLE GATT UUIDs FF00/FF02) — https://github.com/odensc/phomemo-d30-web-bluetooth
- theacodes/phomemo_m02s — https://github.com/theacodes/phomemo_m02s
Libraries
- python-qrcode — https://github.com/lincolnloop/python-qrcode (README)
- Pillow — https://pillow.readthedocs.io/
- bleak (macOS CoreBluetooth support) — https://github.com/hbldh/bleak
- brother_ql (comparison baseline) — https://github.com/pklaus/brother_ql
- niimprint / niimblue (Niimbot, ruled out) — https://github.com/AndBondStyle/niimprint, https://github.com/MultiMote/niimblue