Skip to main content
· Homelab · 8 min read

The Printer That Swallows Jobs: HP P1005 Firmware Reload in an Unprivileged LXC

The failure mode that wastes the most time is the one with no error message. My CUPS server accepted every job, marked it completed, logged nothing unusual — and the HP LaserJet P1005 sat there doing absolutely nothing.

$ lp -d HP_LaserJet_P1005 /etc/hostname
request id is HP_LaserJet_P1005-42 (1 file(s))

$ lpstat -W completed
HP_LaserJet_P1005-42   root   1024   Wed 05 Aug 2026 14:12:03

Clean exit. No paper.

The Printer Has No Firmware

The HP LaserJet P1005 is a GDI printer — a “winprinter”. It has no PostScript interpreter, no PCL interpreter, and, critically, no persistent onboard firmware. HP shipped it as a shell that expects the host to do all the work.

Every time the printer powers on, or its USB link resets, a ~220 KB firmware blob (sihpP1005.dl) has to be pushed to it over USB. Until that happens the device enumerates normally, accepts print data over USB, and silently discards it. Nothing in the CUPS pipeline can tell the difference — the write succeeds, so CUPS marks the job done.

%%{ init: { 'look': 'handDrawn' } }%%
graph TD
    A[Printer powers on] --> B{Firmware<br/>sihpP1005.dl loaded?}
    B -->|Yes| C[USB writes render pages]
    B -->|No| D[USB writes accepted<br/>and discarded]
    D --> E[CUPS sees a successful write]
    E --> F[Job marked completed<br/>no error, no page]

On a normal Debian install this is a solved problem. printer-driver-foo2zjs ships a udev rule — /lib/udev/rules.d/85-hplj10xx.rules — that fires /lib/udev/hpljP1005 on USB device-add and pushes the blob before you notice.

Why It Broke Here

My CUPS server is an unprivileged LXC container on Proxmox. That single fact kills the whole mechanism:

root@cups:~# systemd-detect-virt
lxc

root@cups:~# ls /run/udev/control
ls: cannot access '/run/udev/control': No such file or directory

There is no udev daemon inside the container. The rule file is installed, correctly written, and will never execute. The kernel’s device events are handled on the Proxmox host, where the foo2zjs package isn’t installed and the container’s firmware blob isn’t visible.

So the printer works right after you plug it in and load firmware by hand — and breaks the next time anyone switches it off.

Diagnosis: Read the Device ID String

The tell is in the IEEE 1284 device ID that the CUPS USB backend reports. Run the backend with no arguments and it enumerates devices:

/usr/lib/cups/backend/usb

A printer with firmware loaded reports a FWVER: field. One without it doesn’t. That single substring is the whole diagnostic:

Device ID containsMeaning
FWVER: presentFirmware loaded, printer will print
FWVER: absentFirmware missing, jobs will vanish

Two more things worth checking when the cause isn’t obvious:

# usblp0 removed / re-attached right before a failed job = this exact bug
dmesg -T | grep usblp
# CUPS hides the render pipeline at the default LogLevel
sed -i 's/^LogLevel warn/LogLevel debug/' /etc/cups/cupsd.conf
systemctl restart cups
truncate -s0 /var/log/cups/error_log
lp -d HP_LaserJet_P1005 /etc/hostname
sleep 6
tail -n 200 /var/log/cups/error_log

Revert to LogLevel warn afterwards. Debug logging is noisy and fills a 4 GB container rootfs faster than you would expect.

The Manual Fix

Push the blob through the same USB backend CUPS uses for real jobs:

FW=/lib/firmware/hp/sihpP1005.dl
BACKEND=/usr/lib/cups/backend/usb
URI=$($BACKEND 2>/dev/null | grep -i 'HP.*LaserJet.*P1005' | grep -v FWVER | cut -d ' ' -f2)
DEVICE_URI="$URI" $BACKEND 1 1 1 1 '' $FW

Exit code 0 and roughly 220 KB transferred means it took. Confirm with a one-line job:

lp -d HP_LaserJet_P1005 /etc/hostname

This works. It also has to be repeated after every power cycle, which is not a fix.

The Permanent Fix: A Polling Timer

Since the event-driven path is unavailable, the honest replacement is polling. A systemd timer runs a check every two minutes; the check is idempotent and near-instant when firmware is already present, because it exits after a single USB probe.

%%{ init: { 'look': 'handDrawn' } }%%
graph LR
    A[systemd timer<br/>every 2 min] --> B[Probe USB backend]
    B --> C{P1005 present?}
    C -->|No| D[Exit 0]
    C -->|Yes| E{FWVER: in device ID?}
    E -->|Yes| D
    E -->|No| F[Push 220KB blob<br/>log to journal]

/usr/local/sbin/hplj-p1005-firmware-check.sh:

#!/bin/sh
#
# HP LaserJet P1005 needs its firmware pushed via USB after every power
# cycle / USB reconnect (foo2zjs "winprinter"). Normally a udev hotplug
# rule (85-hplj10xx.rules) does this, but this host is an unprivileged
# LXC container with no udev daemon, so the rule never fires. This
# script polls instead: idempotent, skips if firmware already loaded.
#
set -eu

BACKEND=/usr/lib/cups/backend/usb
FW=/lib/firmware/hp/sihpP1005.dl
PROBE=$("$BACKEND" 2>/dev/null | grep -i 'HP.*LaserJet.*P1005' || true)

if [ -z "$PROBE" ]; then
    # Printer not connected/powered on right now.
    exit 0
fi

if echo "$PROBE" | grep -q 'FWVER:'; then
    # Firmware already loaded.
    exit 0
fi

URI=$(echo "$PROBE" | grep -v FWVER | head -1 | sed -n 's/.*Device URI: \(usb:[^ ]*\).*/\1/p')
if [ -z "$URI" ]; then
    URI=$(echo "$PROBE" | head -1 | cut -d ' ' -f2)
fi

logger -t hplj-p1005-firmware "loading firmware into $URI"
DEVICE_URI="$URI" "$BACKEND" 1 1 1 1 '' "$FW"

/etc/systemd/system/hplj-p1005-firmware.service:

[Unit]
Description=Reload HP LaserJet P1005 firmware if missing
After=cups.service

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/hplj-p1005-firmware-check.sh

/etc/systemd/system/hplj-p1005-firmware.timer:

[Unit]
Description=Periodically check HP LaserJet P1005 firmware

[Timer]
OnBootSec=30s
OnUnitActiveSec=2min
AccuracySec=10s

[Install]
WantedBy=timers.target
chmod 755 /usr/local/sbin/hplj-p1005-firmware-check.sh
systemctl daemon-reload
systemctl enable --now hplj-p1005-firmware.timer

Worst-case detection lag after a power cycle is about two minutes. For a household printer that is invisible — you walk to the printer slower than the timer fires.

AccuracySec=10s lets systemd jitter the wakeup by up to ten seconds so it can batch with other timers instead of forcing its own wake.

Why a timer and not cron

The container is systemd-based, so the timer is the native fit: systemctl status and list-timers for visibility, real After=cups.service ordering, journal logging without writing any logging code, and it survives reboot through enable. Cron would work and would give up all of that for nothing. The script itself has no systemd dependency, so a non-systemd host can drop it into a */2 * * * * crontab entry unchanged.

Setting the Queue Up from Scratch

If you are building this container fresh, the package set matters more than it looks:

apt-get update
apt-get install -y cups cups-client cups-bsd hplip printer-driver-gutenprint
apt-get install -y printer-driver-foo2zjs printer-driver-foo2zjs-common

hplip is HP’s own driver family and it is not what drives this printer here — the P1005’s ZjStream protocol is spoken by foo2zjs’s foo2xqx driver. The hplip path wants a proprietary binary plugin that hplip can’t self-install without a full build toolchain (hp-check -t will tell you so). printer-driver-foo2zjs is also what provides /lib/firmware/hp/sihpP1005.dl in the first place.

lpadmin -p HP_LaserJet_P1005 \
  -v "usb://HP/LaserJet%20P1005?serial=XXXXXXX" \
  -m drv:///foo2zjs.drv/foo2xqx.ppd \
  -E
lpadmin -d HP_LaserJet_P1005

Find your real device URI with lpinfo -v | grep -i usb, and the driver string with lpinfo -m | grep -i p1005. Prefer the foo2zjs: entry over any hplip: or drv:///hpcups.drv alternative.

Apply the firmware timer immediately after adding the queue. Without it, the queue will accept jobs and discard them on this kind of host.

The Proxmox Side

USB passthrough into the container needs to survive device renumbering, which happens on every reconnect. pct config 115 shows two entries doing the work:

dev0: /dev/usb/lp0
lxc.mount.entry: /dev/bus/usb/001 dev/bus/usb/001 none bind,optional,create=dir

The dev0 line is Proxmox-managed device passthrough. The bind mount of the whole USB bus is the one that actually matters — CUPS’s USB backend is libusb-based and talks to /dev/bus/usb, not to the lp0 character device. Because it is a bind mount of a live directory, renumbering on the host propagates into the container with no restart needed.

That part was already correct. The firmware timer was the only missing piece.

What This Generalizes To

Two things worth carrying to the next problem:

A job marked completed only means the write succeeded. It is a statement about the transport, not about the outcome. Any pipeline that ends in a device — printers, serial hardware, tape — can report success into a void.

Unprivileged containers silently drop udev. Anything that depends on hotplug events firing inside the container will be installed, correct, and dead. When you move a service into an LXC, audit what it expects from udev before assuming the port was clean.

Once the printer was reliably printing, it turned out there was a second, entirely separate problem waiting on the Windows side — the same queue produced washed-out grey text from Windows and solid black from Linux.