My printer only speaks Windows

Written on August 29, 2026

How I made an HP LaserJet print on Linux, and then wrote an actual driver for it


So I have this HP LaserJet Pro P1108 plus. Nice little printer. HP ships a driver for it on Windows and that's it. Nothing for macOS, nothing for Linux, and hplip (the open source HP thing that supports like 3000 printers) has never heard of this exact model.

I plugged it into my Linux laptop and it didn't just refuse to print. It turned itself off. Then on. Then off again. Roughly once a minute, forever, like it was breathing.

This is the story of one evening of fixing that, which somehow turned into writing a real driver.

Everything here is downloadable at the end if you just want the files: hp-p1108-pclms-driver.tar.gz

First, what even is a printer driver

Not a kernel module. I assumed it was. It's not.

On Linux and macOS everything goes through CUPS, and a "driver" is honestly just two files:

  your.pdf
     |
     v
  [ CUPS ]      turns your page into a giant grid of dots
     |
     v
  [ a filter ]  converts dots into the printer's own language   <- this is the driver
     |
     v
  [ a backend ] shoves those bytes down the USB cable
     |
     v
  printer goes brrrr

So I needed a PPD (a text file that says what the printer can do) and a filter (a program that turns dots into whatever gibberish this printer wants).

The entire difficulty of this post is that middle box. What gibberish does it want?

Part 1: stop rebooting, please

Before anything else I had to stop the breathing. dmesg told the story:

usb 3-5: New USB device found, idVendor=03f0, idProduct=1b0f
usblp0: removed
usb 3-5: USB disconnect, device number 17
usb 3-5: new high-speed USB device number 18

Device 17, 18, 19, 20... it just kept counting up. The culprit was ipp-usb, a daemon that speaks network-printing-over-a-USB-cable. Modern printers pretend to be a tiny web server, and ipp-usb walks up and politely asks what features they have. Here's its log:

! IPP FaxOut probe failed: HTTP: 404 Not Found
! ESCL: eSCL: HTTP status: 404 Not Found
- Bus 003 Device 013: closed HP LaserJet Pro P1100 plus series

Read that again. You ask this printer whether it can fax, and it dies. It doesn't have a fax. It doesn't have a scanner. Asking about them kills the firmware hard enough to power cycle the whole machine.

And then it gets beautiful: the USB device vanishes and reappears, udev goes "ooh a new printer!", and starts ipp-usb again. Which asks about the fax. Which kills it. Forever.

sudo systemctl stop ipp-usb && sudo systemctl mask ipp-usb

Printer stayed on. First win of the evening.

Part 2: asking the printer what language it speaks

Turns out you can just ask. USB printers carry an IEEE-1284 device ID you can read straight off the control endpoint:

MFG:HP;
MDL:HP LaserJet Pro P1100 plus series;
CMD:PJL,Automatic,PCLM,DW-PCL,DESKJET,DYN;
CID:HPLJPCLMSMV2;

See that CID:HPLJPCLMSMV2? That's the answer. This thing speaks PCLmS. Not PCL, not PostScript, and importantly not the ZjStream format that the older P1100 printers used.

PCLm was invented for printing from phones. It's basically a stripped down PDF where your page is a stack of horizontal image strips, like slicing a photo into ribbons.

So that's why hplip fails. It has an entry for "the old P1108" and that entry describes a completely different print engine. Same name on the box, different machine inside.

Part 3: everything I tried that silently did nothing

Knowing the name of a format is not the same as knowing the format. What I had now was a printer that would happily eat 250 KB of my data and then just... not react.

Sending PCLm over IPP. The printer literally advertises application/PCLm as supported. Send it a job, firmware dies mid transfer:

! USB[1]: send: libusb_submit_transfer: Input/Output Error

Wrapping it in PJL, which is the normal way you talk to HP printers:

\x1b%-12345X@PJL JOB NAME="test"
@PJL ENTER LANGUAGE = PCLM
<page data>

This one is my favourite. It swallows about 4 KB, stalls, and powers off. But a PJL query on its own (@PJL INFO ID) is totally fine. So it's specifically PJL followed by actual work that offends it.

Sending plain PCLm from the normal cups-filters tools. 249 KB accepted in 0.3 seconds. Printer happy. No error, no blink, no page. Nothing.

And that right there is what made this whole thing take so long:

This printer throws bad jobs in the bin and tells you nothing.

An empty print queue means nothing. "Job completed" means nothing. Only paper means it printed. I cannot stress how much time I lost before internalising this.

Part 4: stealing the format out of the Windows driver

The Windows driver works. So the format is knowable. It's just sitting inside a .exe being smug.

HP's installer is one of those self extracting things. file says PE32 executable. 7z won't open it. unzip won't open it. But archives have magic bytes, so I just went looking:

data = open('HPEasyStart-...-Webpack.exe','rb').read()
print(hex(data.find(b'7z\xbc\xaf\x27\x1c')))   # 0x4b267

There's a 7z archive hiding at byte 307815. Slice from there, hand it to py7zr, and out fall 1504 files.

The good one is hpocmraster.4071.dll. Run strings on it and HP's page generator just hands you its entire output format, because it builds the file with printf templates like a normal person:

%%  PCLmS-Job-Ticket
%%      job-ticket-version: 0.1
%%      epcl-version: 1.01
%%    JobSection
%%      job-id: %d
%%    MediaHandlingSection
%%      media-size-name: %s
%%      sides: %s
%%    RenderingSection
%%      pclm-compression-method: RLE
%%      strip-height: %d
%%      printer-resolution: %d
%%      margins-pre-applied: TRUE
%%  PCLmS-Job-Ticket-End
%%PDF-1.7
%%PCLm 1.0

There it is. Before the PDF header there's a job ticket written as plain text PDF comments. My earlier attempt had no ticket at all, which is exactly why it got binned without comment.

Part 5: the bit where I feel stupid

I hand built a file with the ticket, sent it, got a blinking light and no page. Progress! Blinking is more than I'd ever got before. But the body was still wrong somewhere.

Then, on a whim, I ran strings on hpcups. Which was already installed. On my machine. The whole time.

%%PCLm 1.0
%%  PCLmS-Job-Ticket
%%  PCLmS-Job-Ticket-End

The open source driver already has a complete PCLmS generator inside it. hplip's genPCLm.cpp is the same code as HP's Windows DLL. It's just not wired up to my printer, because models.dat doesn't have an entry for it.

So instead of writing anything, I went looking for a printer hplip does support that uses the same engine. Found hp-laserjet_m109-m112, a 2020-ish laser, no proprietary plugin needed. Then I pointed a queue at that PPD but at my printer's USB address:

lpadmin -p P1108S -E \
  -v 'usb://HP/LaserJet%20Pro%20P1100%20plus%20series?serial=XXXXXXXXXX' \
  -m drv:///hpcups.drv/hp-laserjet_m109-m112.ppd

It printed. A whole evening of a printer having seizures, solved by turning off one daemon and lying about which printer I own.

Part 6: okay but the Mac still has nothing

Linux was done. But my Mac has literally zero drivers available for this printer, and macOS runs CUPS too, so the same trick doesn't exist there. It needed an actual filter.

Good news: now I had a working file, so I could just read one instead of guessing.

%PDF-1.7
%PCLm 1.0
%  PCLmS-Job-Ticket ...          <- the settings, as PDF comments
3 0 obj  << /Type /Page
            /XObject << /Image0 5 0 R  /Image1 7 0 R ... >>
4 0 obj  << stream               <- where to put each strip
            0.120000 0 0 0.120000 0 0 cm      (72/600, so units become pixels)
            5100 0 0 64 0 6536 cm  /Image0 Do Q
            5100 0 0 64 0 6472 cm  /Image1 Do Q
5 0 obj  << /Width 5100 /Height 64
            /Filter /RunLengthDecode
            /Name /WhiteStrip >>  <- actual pixels, RLE squished

The page is 5100 by 6600 pixels (that's 8.5 by 11 inches at 600 dpi), chopped into strips 64 pixels tall, stacked bottom to top. Compression is PDF's RunLengthDecode, which is just PackBits, which is about 20 lines of C.

So I wrote rastertopclms.c. Reads CUPS raster, writes the ticket, encodes strips, writes the xref table. Output came out structurally identical to HP's.

It printed a completely blank page.

Part 7: two real bugs and one very embarrassing one

Bug one: my strips were too narrow. Mine were 4867 pixels wide. HP's were 5100.

4867 is the imageable area, which is what CUPS hands you after subtracting the margins. 5100 is the whole physical sheet. HP sends the entire sheet with the margins painted white.

That's what margins-pre-applied: TRUE in the ticket actually means. Don't send me the printable bit, send me the whole page with the borders already drawn in. Give this firmware a narrower strip and it feeds you a blank sheet rather than, you know, mentioning it.

Bug two: I was painting into the bottom margin. Fixing the width upgraded me from "blank page" to "blinking light, no paper at all". Comparing strip layouts on the same document:

HP:   102 strips, y = 118 .. 6600
mine: 104 strips, y = 0   .. 6600

HP stops at the bottom margin. My last strip sat at y=0, inside the unprintable border, and that got the entire job rejected. Clamped it to the last raster row and landed on exactly 102 strips, same as HP.

Bug three, the humbling one. This whole time I'd been testing by shoving bytes directly at the USB endpoint with a little pyusb script, because it was faster than going through CUPS. Pages kept coming out blank.

Eventually, out of desperation, I sent HP's own known-good file through my script. Blank page.

My test harness was broken. Had been for hours. The format was probably fine several fixes ago. Sending the same bytes through a dumb raw CUPS queue instead:

lpadmin -p P1108RAW -E -v 'usb://HP/...'   # no PPD = raw queue
lp -d P1108RAW -o raw mine.pclms

Both files printed. Mine and HP's, side by side, identical.

So, free lesson, learn from my pain: when you're debugging an unknown format, test your transport first by sending known-good data through it. I skipped that step and spent an entire evening blaming my encoder for a crime it did not commit.

Part 8: the Mac, for real this time

I wrote rastertopclms.c for the Mac and then never actually ran it on the Mac. It was verified on Linux, against the real printer, and I told myself it was portable C against system CUPS so it would be fine.

Plugged the printer into the MacBook. Two things went wrong immediately, neither of them the driver.

The install script picked /usr/share/cups/model for the PPD, because that directory exists on macOS. It does exist. It is also on the sealed, read-only system volume, so writing to it fails even as root. On macOS the PPD belongs in /Library/Printers/PPDs/Contents/Resources, and the filter goes in /usr/libexec/cups/filter which, despite living under /usr, is on the writable data volume. The script now probes for somewhere it can actually write instead of trusting that a directory existing means anything.

Then macOS tried to add the printer as AirPrint. AirPrint over USB is IPP-over-USB. IPP-over-USB is the thing from Part 1 that makes this printer breathe. You have to open the "Use:" dropdown and pick the driver by hand.

After that it printed. Forty lines of b.

The nice part is what that run covered that Linux never did. It was A4, not Letter, so the whole-sheet canvas and the bottom-margin clamp from Part 7 got proven on a second geometry. And macOS renders through cgpdftoraster, which hands the filter 24-bit RGB, where hpcups on Linux hands it 8-bit grey. That colour branch had never touched paper in its life.

Part 9: the bug that was waiting for a photo

The printer still refuses to tell me anything, so I finally did the thing I should have done back in Part 7 and built a way to test without it.

It synthesises CUPS raster, runs the filter under AddressSanitizer, then parses the PCLmS back out, decodes every strip, restacks the page and compares it pixel by pixel against what went in. No printer, no paper, no guessing.

It crashed on the first run.

The comment above my RLE encoder said "worst case is 1 extra byte per 128 input bytes". That was confidently, expensively wrong.

RunLengthDecode has two kinds of chunk: repeat this byte n times, which costs 2 bytes, and copy the next n bytes literally, which costs 1 byte plus the bytes. My encoder breaks off a literal chunk the instant it sees two identical bytes in a row. So on data shaped like 00 FF FF 00 FF FF, every lone 00 becomes its own 2-byte literal chunk. Three bytes in, four bytes out.

The real worst case isn't 1.016x. It's 2x. And I had sized the output buffer for 1.016x.

00 FF FF is not a contrived pattern I went looking for. That is what halftoning looks like: the dither a mono laser uses to fake grey. Any photo. Any gradient. Any scanned document.

raw bytes      : 326400
buffer         : 331564
RLE produced   : 435201

AddressSanitizer put it plainly: heap-buffer-overflow, 103,637 bytes past the end of the allocation, every single time.

It never surfaced because every page I had ever printed while building this was text, and text compresses about 55x. The bug was sitting there waiting for someone to print a photo.

The fix is one line. The lesson is the harness. This is the same mistake as Part 7's broken test script wearing a different hat: I had no way to check my own output, so I only found out what worked by feeding paper to a machine that refuses to explain itself.

While I was in there I also learned the harness catches things I never thought to test at all, like multi-page jobs, which turn out to need exactly one job ticket for the whole job and one page object per sheet. That worked already. I just had never checked.

One thing I measured and did not ship: standard PackBits breaks literal chunks on runs of three rather than two, which is byte-identical on text, blank pages, gradients and noise, and 24% smaller on halftones. It's a better encoder. But changing the bytes I send is exactly the kind of thing this printer will punish silently, so it stays on the shelf until I've watched a halftoned page come out with the new encoder in place. Printing text to check would prove nothing: text never goes anywhere near the bound that was crashing.

Installing this on a Mac

If you have this printer and a Mac, here is the whole thing. Grab the archive, unpack it, and:

cd hp-p1108-pclms
make
sudo ./install-driver.sh

That builds the filter, drops it and the PPD in the right places, finds the printer on USB and creates the queue. Then print something:

lp -d P1108 -o media=A4 /etc/hosts

If you would rather add the printer through System Settings, or the script's queue creation falls over, the manual version is four commands:

# 1. build
make

# 2. install the filter and the PPD (these paths are not interchangeable)
sudo install -m 0755 rastertopclms /usr/libexec/cups/filter/rastertopclms
sudo install -m 0644 hp-p1108-pclms.ppd \
     /Library/Printers/PPDs/Contents/Resources/hp-p1108-pclms.ppd

# 3. find the printer
lpinfo -v | grep usb
# direct usb://HP/HP%20LaserJet%20Pro%20P1100%20plus%20series?serial=XXXXXXXXXX

# 4. create the queue
sudo lpadmin -p P1108 -E \
  -v 'usb://HP/HP%20LaserJet%20Pro%20P1100%20plus%20series?serial=XXXXXXXXXX' \
  -P /Library/Printers/PPDs/Contents/Resources/hp-p1108-pclms.ppd \
  -o printer-error-policy=retry-job

Yes, the USB string really does say P1100 plus series. That is what the P1108 firmware reports about itself. It fooled me for a while too.

Three things that will bite you:

Do not let macOS add it as AirPrint. If you go through System Settings, open the "Use:" dropdown, pick "Select Software...", search P1108 and choose HP LaserJet Pro P1108 plus, PCLmS (rastertopclms). The default macOS offers is AirPrint, which over USB means IPP-over-USB, which is Part 1's breathing printer. If it starts power cycling, unplug it, turn it off and on once, delete the queue and add it again with the driver picked by hand.

If the Printer Software list doesn't show the driver, close the window and reopen it. CUPS caches that list, so a PPD you installed while the dialog was open won't appear.

Don't install the PPD to /usr/share/cups/model. That directory exists on macOS, which is exactly the trap: it's on the sealed read-only system volume, so the copy fails even under sudo. /Library/Printers/PPDs/Contents/Resources is the one that works.

To check it actually wired up the way you want:

lpstat -v                     # must say usb://, not ipp://
grep -i 'NickName\|cupsFilter' /etc/cups/ppd/*.ppd
# *NickName:   "HP LaserJet Pro P1108 plus, PCLmS (rastertopclms)"
# *cupsFilter: "application/vnd.cups-raster 0 rastertopclms"

And if nothing comes out of the printer, run make test before you start suspecting the driver. It'll tell you whether the filter is emitting sane PCLmS, which at least splits "my code is wrong" from "the firmware is sulking" — a distinction this printer will never draw for you.

Where it all landed

  • Linux: no code needed at all. Mask ipp-usb, point a queue at the hp-laserjet_m109-m112 PPD, done.
  • macOS: rastertopclms.c, about 400 lines, links against system CUPS, builds with clang. Verified on real hardware, printing over USB, at A4.
  • The house: a Raspberry Pi Zero 2 W runs the Linux setup and shares the queue over mDNS, so every phone and laptop here can print without installing a single thing.

The archive has the filter source, both PPDs, an install script for macOS and Linux, the Pi setup script, the offline test harness, and a README listing everything that didn't work: hp-p1108-pclms-driver.tar.gz

The hardware was never broken. It's a perfectly good printer with firmware that dies if you ask about a fax machine it doesn't have, speaking a file format that only existed inside a Windows DLL.

PS: I only tested this on my own P1108 plus, so no promises. But if you've got some other printer that prints fine from Windows and does nothing on Linux, it's worth reading its device ID and seeing whether some printer hplip already supports uses the same engine. That's the trick that worked here, and I found it embarrassingly late.