Scanning again from an older HP MFP that macOS dropped support for — HP LaserJet /
OfficeJet all-in-ones that still print fine over AirPrint but whose scanner vanished
somewhere around macOS Ventura. Runs HPLIP in an on-demand Debian VM and gives you a
scanbox command.
Developed against an HP LaserJet Pro 200 color MFP M276nw (2013) on Apple Silicon.
Lots of people hit "my HP scanner stopped working on macOS", and most of them have an easier fix than this: newer printers support eSCL (AirScan) and merely ship it disabled. Browse to your printer's IP, look for eSCL or AirScan under Network or Scan, turn it on, and macOS will find it in Image Capture. Try that first.
This repo is for the harder case: printers that have no eSCL at all. They predate the standard and speak only HP's proprietary SOAP scan protocol (TCP 8289) and WSD. There is no setting to enable, HP has stopped updating the firmware, and macOS no longer ships vendor scanner drivers. Printing keeps working because AirPrint covers printing only.
You are probably in this case if:
- the printer is roughly 2009–2015 vintage,
- its embedded web server has no eSCL/AirScan option anywhere,
- it prints fine but no longer appears as a scanner,
- and
dns-sd -B _uscan._tcpshows nothing for it, whiledns-sd -B _scanner._tcpdoes.
HPLIP's hpaio backend still implements the old protocol. Linux can therefore still
talk to these printers perfectly well — so this runs a small Debian VM and drives it
from a CLI.
macOS ── scanner ──▶ Debian VM ── HPLIP/hpaio ── HP SOAP:8289 ──▶ printer
(CLI) created and started on demand,
stopped again when idle
The VM is a runtime component, not an installation: created on first use, started when needed, stopped after 60 idle minutes. Scanning here happens a few times a year and nothing should be running in between. Cold start to a finished scan is ~21s.
Developed and tested against one printer, on one Mac, on Apple Silicon. It is built from generic pieces — HPLIP, SANE, Bonjour discovery, no model hardcoded in the scan path — so other pre-eSCL HP MFPs have a good chance of working. But "good chance" is the honest claim; it has not been tried on a second device. Reports welcome.
Intel Macs should work (Lima supports both) but are likewise untested.
brew tap vincentcr/scanbox
brew trust vincentcr/scanbox # Homebrew 6+ gates third-party taps
brew install scanbox # pulls in lima
scanbox setup # find your scanner and save itOr without Homebrew:
brew install lima # the one prerequisite; scanbox reports it, never installs it
git clone https://github.com/vincentcr/scanbox && cd scanbox
./bin/install # symlinks scanbox into ~/.local/bin
scanbox setupscanbox setup lists the scanners it finds and writes your choice to
~/.config/scanbox/config. It confirms before replacing an existing config, and
nothing is written until the whole flow completes. --host=NAME skips discovery;
--overwrite skips the confirmation.
scanbox scan # feeder if loaded, else the bed
scanbox scan feeder # force the document feeder
scanbox scan bed # force the flatbed
scanbox setup # find a scanner and save it as your config
scanbox status # VM state, config, resolved address
scanbox stop # stop the VM nowRunning scanbox with no arguments prints help. Scanning moves paper, so it needs
the explicit scan verb rather than being the default action.
PDFs land in ~/Pictures/Scans/scan-YYYYMMDDHHMMSS.pdf.
| option | |
|---|---|
--out DIR |
where scans land (default ~/Pictures/Scans) |
--name NAME |
base filename |
--dpi N |
75–1200, default 300 |
--mode M |
Color|Gray|Lineart |
--page P |
auto|letter|legal|a4|max |
--format F |
pdf|png|tiff|jpeg (default pdf) |
--lossless |
disable the scanner's in-transit JPEG compression (slow — see below) |
--keep-alive MIN |
idle minutes before the VM stops (default 60) |
--printer HOST |
override the configured scanner for one run |
A feeder run collates into one PDF, with each sheet sized from its own trailing edge — feed two letter pages and a legal one and you get a PDF with two letter pages and a legal page. Every page is kept, blanks included.
Config lives at ~/.config/scanbox/config (see config.example) and is not committed.
Each of these cost real debugging. They are the reason this repo exists at all, more than the code is.
1. Scanning needs HP's closed-source plugin, and HPLIP's own metadata misleads.
models.dat reports plugin=0 for this model, which reads as "no plugin needed" —
that flag covers printing only. Scanning is scan-type=5 (SOAPHT), implemented in
bb_soapht.so, which ships only in HP's proprietary plugin. Without it scanimage
fails with a bare Error during device I/O, and the real cause appears only in the
journal:
limactl shell scanbox -- sudo journalctl | grep scanimageHP's own CDN 403s on the plugin download; provision/20-plugin.sh uses the
OpenPrinting mirror. bb_soapht-arm64.so exists, so Apple Silicon needs no emulated
x86 VM.
hp-plugin's exit status is also unusable: yes takes SIGPIPE when it exits, so the
pipeline reports 141 under set -o pipefail even on success. The installed .so is
the only trustworthy signal.
2. Discovery happens on the host, never in the guest.
Multicast does not cross Lima's vzNAT boundary, so scanimage -L inside the VM finds
nothing. All Bonjour work is done by macOS (scanbox/discover.py) and only a plain IPv4
address is passed in. _scanner._tcp is the right service type — close to exactly the
set hpaio can drive, and its TXT record carries the model and feeder=T/flatbed=T.
This is also why the printer is configured by mDNS name rather than IP: the name derives from the printer's MAC, so it survives DHCP moving the address.
3. The ADF cannot report page length — but the sheet edge is measurable. The feeder always runs its full 381mm (15") travel whatever you feed it, so raw scans are 15" tall. Inferring size from where the ink stops is wrong: a letter sheet with content only in its top half would come out A5.
So lib/autofit.sh measures the sheet instead. Past the trailing edge the scanner
images its own backing, which returns a perfectly constant 254 (65278 at 16-bit)
while paper reads 255 or textured. The physical edge is where that constant run
begins — independent of content, and correct for a blank legal page.
Two thresholds matter. Paper white sits only 257 above the backing at 16-bit — one 8-bit level — so a loose tolerance reads the whole sheet as backing. And the snap tolerance must be proportional, not fixed: the feeder grips differently on each pass, and one legal sheet measured anywhere from 13.77" to 14.12" across runs.
ADF only. The flatbed has no backing to measure against, since the lid is the same white as paper.
4. A short feeder batch must never look like a clean scan. A batch has exactly one healthy ending — the feeder reporting it is out of documents. A jam or mis-feed partway leaves a batch that stopped early, which is otherwise indistinguishable from success and quietly yields a short PDF. That is how a page goes missing without anyone noticing, so anything else is flagged loudly.
(A double-feed, where several sheets are pulled through as one, stays undetectable in software — the page count and sheet count simply disagree. Compare against what you loaded.)
5. File format is not where quality is lost. The default PDF output is lossless
(/FlateDecode) and smaller than TIFF. The scanner JPEG-compresses in transit
before the data ever reaches us — the only lossy step, which no output format
recovers. Use --lossless when it matters.
--format picks what lands on disk: pdf (the default, lossless, and stays that
way regardless of this option) collates everything into one file, which is what you
want for a multi-page document you'll read or print as a whole. --format tiff is
also lossless and multi-page, and is the better pick for long-term archival, since
some tools trust TIFF over PDF for that. --format png skips PDF assembly entirely
and just saves the scanned pages as-is — it's the fastest path, and the only one
that's lossless end-to-end with zero re-encoding, which makes it the right choice
for a single photo. --format jpeg is there for when small size matters more than
fidelity; combining it with --lossless is self-defeating (a warning says so) since
you'd be paying for an uncompressed transfer only to re-compress it on disk anyway.
It is not free. Without the in-transit JPEG the whole raster crosses the network uncompressed, and this printer's SOAP transfer measures about 550 KB/s:
| bytes on the wire | time per page | |
|---|---|---|
--lossless --dpi 300 |
27 MB | ~50 s |
--lossless --dpi 600 |
107 MB | ~3 min |
--lossless --dpi 1200 |
430 MB | ~13 min |
A 1200 dpi lossless page really does take a quarter of an hour, so scan prints the
estimate up front and then reports a live percentage and ETA. Cancelling is safe —
Ctrl-C stops the scan inside the VM as well as on the host. Note that the printer
holds its one scan session for roughly 45 seconds after an aborted scan; scan
retries through that window rather than failing.
The host side is Python — standard library only, no virtualenv, nothing to install beyond an interpreter. It targets 3.9, which is what the macOS Command Line Tools ship, and Homebrew requires those anyway, so a Mac that can install this can already run it.
Everything that runs inside the VM stays shell (lib/guest-scan.sh, lib/autofit.sh,
provision/). Those are piped in over stdin, so the VM never learns where this repo
lives — and there is no reason to give a Debian guest a Python dependency.
The lock is the one piece that did not change: mkdir is still the mutex, because
macOS has no flock and this has to work between processes. Two scanbox scan
invocations are two processes, so an in-process lock would not be in the conversation.
The host was bash until v0.5.0, which meant working around macOS's bash 3.2 and its
missing flock/setsid/timeout — nohup for detaching, perl for timeouts, and no
mapfile or associative arrays. None of that survives; git log has it if you are
curious.
scanbox owns it entirely. There is no launchd agent — a permanently registered
background job is the thing this design avoids. After each scan it arms one detached
timer, guarded by a pidfile, which stops the VM once idle.
limactl shell scanbox # poke around inside
limactl delete scanbox # start over; the next scan rebuilds itFirst ever run takes several minutes: it downloads the Debian image and the HP plugin.
- AirSane — publishes SANE scanners over eSCL. Excellent, but assumes you already have a machine running SANE.
- node-hp-scan-to — reimplements HP's "scan to computer" for a different subset of printers.
- sane-airscan — eSCL and WSD client for Linux. Its WSD support is a plausible alternative path for these printers.
An earlier version of this repo bridged the scanner to Preview and Image Capture via
AirSane, so it appeared as a native macOS scanner. That was dropped in favour of a
CLI-only design, since keeping it meant keeping a VM and a Bonjour advertiser running
permanently for a few minutes of use a year. To get it back, branch from ea3d852.
MIT