Files
deploy_home/ansible/roles/labelprint/README.md
T
Bastian de BylandClaude Opus 5 6cd4d56de1 feat(labelprint): 4x6 label print proxy on a Raspberry Pi
A Pi 3B+ (stickah.local) shares a Phomemo PM246 to the LAN as a plain CUPS
queue, so any machine can print 4x6 labels -- fulfillr-site's shipping labels
in particular -- without installing the vendor driver, which is x86-64 only.
The role builds the TSPL CUPS driver from source instead.

It is Debian, not Fedora, so it lives in its own inventory and playbook
(make deploy-labelprint / check-labelprint) and the home.debyl.io roles can
never run against it. make bootfs renders its cloud-init first-boot files onto
a freshly imaged SD card from the same templates the role uses. The Wi-Fi
credentials for the home and rescue networks are in the vault.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:14:45 -04:00

7.0 KiB

labelprint — 4x6 label print proxy

A Raspberry Pi 3B+ (stickah.local) with a Phomemo PM246 on USB, shared to the LAN so any Mac, Windows or Linux machine can print 4x6 labels without installing a printer driver. Built for fulfillr-site, which prints shipping labels straight out of the browser, but the queue is a plain 4x6 label printer and anything can use it.

Deployed on its own:

make deploy-labelprint
make check-labelprint            # dry run
make deploy-labelprint TAGS=cups # just the queue

Why not the Phomemo driver

The vendor driver that runs on yoga installs /usr/lib/cups/filter/rastertolabeltspl, and that file is an x86-64 ELF. There is no ARM build, so none of it can be reused on the Pi.

The PM246 speaks TSPL over USB, so this role builds RunTheWall/tspl-cups-driver (MIT) from a pinned commit instead: a CUPS raster filter, a backend and a PPD, about a minute of compiling on the Pi. Pinned to a commit rather than installed from the project's apt repo so that a version bump is a reviewed change in this repo, and so the Pi carries no third-party signing key.

The PM246's USB id is not in the driver's auto-detect list. tspl://auto is tried first; if the queue cannot find the printer, read the id off the Pi and pin it — see labelprint_device_uri in defaults/main.yml.

The image

The SD card image is built by hand, but the cloud-init files on it are not: make bootfs renders them from the same templates the role uses, so the Pi boots with its Wi-Fi profiles, its rescue access point and its login already in place. Nothing here needs the printer driver — that is Ansible's job.

  1. Write the image with rpi-imager: Raspberry Pi OS (64-bit), Bookworm or newer, so NetworkManager is the network stack. Verified against the 2026-06-18 pi-gen build. Skip the customisation screen entirely — anything set there is about to be overwritten.

  2. Render the boot files onto the mounted boot partition:

    make bootfs                        # defaults to /Volumes/bootfs
    make bootfs BOOTFS=/path/to/boot
    

    It refuses to write anywhere without a config.txt, and keeps whatever rpi-imager wrote as *.rpi-imager.bak.

  3. Eject, boot the Pi, and give cloud-init a couple of minutes.

  4. make deploy-labelprint.

make bootfs is safe to re-run. The instance id is a hash of the rendered user-data, so an unchanged render leaves the card alone, and a changed one makes the Pi re-apply it on the next boot.

That id is written in two places, and they have to agree: meta-data, and the ds=nocloud;i=<id> token rpi-imager puts on the kernel command line in cmdline.txt. The command line wins. Leave rpi-imager's id there and a card that has booted even once looks like the same instance to cloud-init forever — it skips users, write_files and runcmd without a word, and the only symptom is a Pi that came up with none of this applied. make bootfs rewrites both.

To force a re-apply on a Pi that is already running, without pulling the card:

sudo sed -i 's/;i=[^ ]*/;i=<new-id>/' /boot/firmware/cmdline.txt
sudo cloud-init clean --logs
sudo reboot

Getting in

  • ssh stickah@stickah.local — your ~/.ssh/id_ed25519.pub is installed, and that is what Ansible uses.
  • Password auth is on, with the password stickah. It is deliberately trivial and deliberately not hashed: this is the fallback for a Pi that will not take the key — reflashed card, someone else's laptop, standing at the bench — and port 22 is reachable only from the LAN or from the rescue AP, which has a real WPA2 password of its own. If that trade stops being the right one, labelprint_user_password in defaults/main.yml is the only thing to change.

Wi-Fi and the rescue AP

Normally the Pi is a station on the home SSID. When that network is unreachable — the password changed, the router died, the Pi moved — the wifi-rescue watchdog brings up an access point so there is still a way in:

  • Join the rescue SSID (both it and its password are in the vault).
  • The Pi is at 192.168.4.1: ssh pi@192.168.4.1, or print directly to ipp://192.168.4.1:631/printers/labels.
  • Deploy to it there with make deploy-labelprint EXTRA_VARS="ansible_host=192.168.4.1".

The Pi also keeps the Wi-Fi profile netplan rendered from the image's original network-config, at a lower autoconnect priority than home-wifi. It is a deliberate fallback: if home-wifi is ever rendered wrong, the Pi still comes back on the LAN rather than stranding itself on the rescue AP.

The 3B+ has one radio and cannot hold an AP and a station link at the same time, so the watchdog cannot listen for the home SSID while the AP is up. Instead the AP drops every five minutes to scan -- about seven seconds if the home SSID is plainly gone, up to half a minute if it is worth a join attempt -- and comes back immediately if the home network is still missing. When the home SSID does return, the Pi rejoins it and shuts the AP down by itself.

If you are working over the rescue AP and do not want your session cut:

touch /run/wifi-rescue.hold

The hold expires after 30 minutes, so a forgotten hold file cannot strand the Pi. Deploys take the hold automatically for as long as they run.

Watch it work with journalctl -t wifi-rescue -f.

Adding the printer from a client

Nothing to install anywhere — the Pi renders.

  • macOS — System Settings → Printers → +. It appears under its own name as an AirPrint printer. (BrowseDNSSDSubTypes _print,_universal in cupsd.conf is what makes macOS offer the driverless add instead of guessing at Generic PostScript.)
  • Windows 10/11 — Add printer; it is discovered as IPP Everywhere / Mopria.
  • Linux — discovered by cups-browsed, or add ipp://stickah.local:631/printers/labels by hand.

Access

LAN only, 192.168.1.0/24, plus the rescue AP subnet. Enforced twice on purpose: at nftables (templates/nftables.conf.j2) and again in cupsd's own Location blocks (templates/cupsd.conf.j2). CUPS is explicitly set --no-remote-any; the upstream driver's install.sh turns that on, which would offer this printer to anything that can route to the Pi.

The Pi patches itself: unattended-upgrades is on, with the Raspberry Pi archives added to the origins allowlist (Debian's default covers only the Debian security origin, which would leave the kernel and firmware — the packages most specific to this hardware — unpatched). Kernel updates reboot at 04:00. Nothing here holds state across a reboot; a queued job is spooled to disk and resumes.

Secrets

In ansible/vars/vault.yml (make vault), no vault_ prefix, per repo convention:

Key What
stickah_ssid Home SSID
stickah_psk Home passphrase, or the 64-hex precomputed PSK
stickah_ssid_rescue Rescue AP SSID
stickah_psk_rescue Rescue AP passphrase (WPA2, 8+ characters)