Skip to content

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 id 1467113436. 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 0 raster bit image wrapped in Phomemo-specific 0x1f 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/bleak using 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, lp from 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=3 QR 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 forced box_size=2 does 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

  1. Install Phomemo's macOS driver from https://phomemo.com/pages/download-center-m110 (QY_Printer_Driver_Mac.zip).
  2. 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' bluetooth backend needs classic SPP pairing, which the M110 does not offer.
  3. just docs label-ppd, then run the printed lpadmin line (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_d30 sends 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.py vs rastertopm110.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.

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:

  1. Vendor: "The M110 uses 203 dpi resolution" — Phomemo M110 setup guide.
  2. Driver: *Resolution - 8 0 0 0 203dpi appears in both phomemo-d30.drv and phomemo-m110.drv.
  3. Arithmetic cross-check: the sniffed D30 init header 1f1124001b401d7630000c004001 decodes 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=...) — "The version parameter is an integer from 1 to 40 […] Set to None and use the fit parameter 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 default convert("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) // 8 bytes per row (bytes_per_line in rastertopd30.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:

  1. just generates the PNG at exact native pixel dimensions (above).
  2. AirDrop it to the iPhone.
  3. 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:

  1. 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 just recipe (label) that writes config/<device>/label.png.
  2. 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.
  3. 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

Open-source drivers (community-reverse-engineered — no vendor protocol spec exists)

Libraries