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

163 lines
7.0 KiB
Markdown

# 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`](../../../../debyltech/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](https://github.com/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](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](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](templates/nftables.conf.j2))
and again in cupsd's own `Location` blocks
([templates/cupsd.conf.j2](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) |