The Problem
The site needed product replacement stickers — small UPC-barcoded labels applied to returned, refurbished, or repackaged products before they go back into inventory. The existing process was slow and error-prone: locate the right pre-printed sticker, hope you grabbed the correct part number, repeat. With a catalog of 13 stickers all following a strict naming convention (sticker-NN-PARTNUMBER-VARIANT), the goal was a tool a tech could use at a workbench: scan or type a part number, pick a quantity, print.
Constraints worth naming up front: it had to run as an always-on appliance on a dedicated workstation, print clean scannable barcodes, and the lookup had to be forgiving — sometimes you have a UPC, sometimes a part number, sometimes just a filename from the booklet.
Architecture
The system is a single Flask app (scan_app.py) running on a Debian-based Dell AIO, bound to localhost:8080. A systemd user service launches it at login and keeps it running. The user submits a barcode or part number; the app resolves it to a sticker PNG, converts the PNG to ZPL using zebrafy, injects the requested print quantity, and ships the ZPL bytes directly to the Zebra over USB.
Browser → Flask (scan_app.py) → Lookup chain → PNG → zebrafy → ZPL
↓
pyusb (bulk OUT)
↓
USB → Zebra ZM400
Three pieces did most of the work:
- The lookup chain — UPC lookup via
upc_map.json, then full filename stem match, then part number extracted from the third hyphen-delimited segment of the filename. M/N-prefixed part numbers resolve directly from filenames without needing a JSON entry. - The PNG-to-ZPL pipeline —
zebrafyconverts each sticker PNG to ZPL on the fly. Quantity gets injected by inserting a^PQcommand directly before the closing^XZ. - Direct USB printing — Zebra devices share USB vendor ID
0x0a5f. The app enumerates USB devices matching that VID, claims the interface, finds the bulk OUT endpoint, and writes raw ZPL bytes. No CUPS queue, no PPD, no driver.
Key Decisions
-
Thermal transfer, not direct thermalInitial evaluation included a Zebra ZP505 (direct thermal). On 1.5"×1" labels with detailed artwork, the dithered output was unusable — direct thermal is essentially binary (dots on/off), and grayscale is faked through dithering that degrades sharply at small sizes. The ZM400 with thermal transfer ribbon produces crisp output suitable for both barcodes and graphics.
-
Direct USB via pyusb, not CUPSCUPS works for Zebras with the right PPD, but adds moving parts: a system service that can hang, a queue that can jam, driver packages that drift between distro versions. Writing ZPL bytes straight to the bulk OUT endpoint over USB is simpler and more deterministic — fewer layers between "user clicked print" and "ribbon advances." The tradeoff is needing a udev rule for non-root USB access, which the installer handles once.
-
Three-tier lookup, not a single canonical keyForcing every sticker to be looked up by UPC would have meant maintaining a JSON entry for every part number. Forcing filename-only would have meant typing exact filenames. The chained approach — UPC first, then filename, then part-number-from-filename — lets the user query however they have the data, and keeps the JSON file minimal.
-
Quantity via ZPL injection, not multiple print callsSending the same ZPL N times works but is slow and shows N print jobs. Injecting
^PQ{n}before^XZuses the printer's native quantity command — one job, much faster, cleaner USB traffic. -
Systemd user service, not system serviceThe service runs as the logged-in user, not root, which limits blast radius if anything goes sideways.
loginctl enable-lingerkeeps it running even with no active session — important for an always-on workstation that survives a reboot without someone logging in. The installer enables linger automatically. -
Auto-lookup at 7 charactersThe input field auto-submits when the user reaches the threshold, eliminating the need to press Enter for barcode scanner workflows. Set to 7 (not 8) to catch 8-character part numbers like
M1039201on the keystroke before the scanner's trailing newline arrives. Tuned by trial. -
Dithering threshold 200, not the default 128For PNG-to-1-bit conversion, the library default of 128 produced muddy output on thermal labels — too much black, lost detail in barcodes. 200 pushed the threshold toward white, producing cleaner, more scannable barcodes and crisper graphics. Verified by physically scanning printed output.
Lessons Learned
USB permissions live in udev, not in the app
The instinct when a USB device returns a permission error is to debug the app or the user account. The fix is upstream: a udev rule (99-zebra-printer.rules) that grants the plugdev group access to devices matching the Zebra vendor ID. Once the rule is installed and the user is in plugdev, the app never has to think about permissions. Forget the rule and every print call returns a USBError that looks like an app bug.
Linux changes the deployment story more than the code
Porting scan_app.py from Windows to Linux meant swapping win32print for pyusb — maybe sixty lines of change. The bigger work was deployment: the installer script, the udev rule, the systemd unit file, the plugdev group membership, the enable-linger call, the desktop shortcut. The Linux version is more robust at runtime but front-loads more setup. A one-command install.sh made the tradeoff worth it.
Transparent PNGs render as solid black
Thermal printer drivers treat transparent pixels as ink-on. Source PNGs with transparent backgrounds came out as completely black labels. Fix: composite every PNG onto a white background before the 1-bit conversion. Easy in retrospect, baffling the first time it happened.
"Invert" should be framed as "deviation from correct"
Early UI had an invert checkbox labeled "Apply invert." Users got confused about when to check it. Reframed as "Invert from normal" — checked means "this sticker prints wrong without it" — and confusion vanished. Same code, better mental model.
Use Path(__file__).parent everywhere
The app needs to find stickers/ and upc_map.json regardless of where it's launched from — systemd, a desktop shortcut, a terminal in a different working directory. Anchoring every path to the script's own location instead of os.getcwd() eliminated an entire class of "works when I run it manually" bugs.
Ribbon spec matters as much as label spec
The ZM400 requires a 1" core ribbon; the labels are 1.5"×1" on a 3" core, 8" OD roll. Mismatch either dimension and you spend an afternoon on the printer's "ribbon out" error before realizing the consumable, not the printer, is wrong.
Verify barcodes by actually scanning them
UPC accuracy can't be confirmed by reading digits — humans transpose numbers, especially when half the digits are tiny under a barcode. Final QA step on the booklet was running a handheld scanner across every printed sticker and checking the decoded value against the source-of-truth list.
Stack
Status & Outcomes
Deployed to a Debian-based Dell AIO at the workbench. One-command install (bash install.sh) handles the venv, Python dependencies, udev rule, systemd unit, plugdev group membership, linger enablement, and a desktop shortcut to http://localhost:8080. Survives reboots without manual intervention.
Replaced a workflow that required physically locating pre-printed sticker stock with an on-demand print-from-browser process. The accompanying reference booklet (sticker_booklet.pdf, ReportLab-generated) gives techs a visual catalog when they don't know the part number or UPC.
A Windows build of the same app exists for environments without Linux access — same UI, same lookup logic, win32print instead of pyusb. The two codebases are maintained separately rather than trying to keep one cross-platform binary; the deployment surfaces are different enough that the abstraction wasn't worth the complexity.