I've had a Fujitsu fi-5120C sat on a shelf for a while and a Paperless-ngx instance quietly waiting for documents. The goal was embarrassingly simple to state: put twenty or thirty sheets in the ADF, press the Scan button on the scanner itself, walk away, and find a searchable PDF in Paperless a couple of minutes later. No PC, no web UI, nothing to click.
Getting there took considerably longer than stating it, mostly because three of the things I "knew" turned out to be wrong. Two of those were wrong in ways that would have silently eaten documents, which is precisely what you don't want from a document archive.
The setup
The scanner is a Fujitsu fi-5120C (USB, ADF duplex, ultrasonic double-feed sensor). Paperless-ngx 2.20.15 runs in an LXC container on Proxmox. The scanner itself hangs off a second Proxmox node with an unprivileged LXC doing the capture — though everything here works just as well on a Raspberry Pi, and I'll come back to that because the Pi threw up an interesting wrinkle.
Software: sane-backends 1.2.1, scanbd 1.5.1, Debian 12 and 13.
USB passthrough that survives a replug
First job: get the scanner into an unprivileged container without pinning it to a physical port. The obvious approach — Proxmox's dev0: /dev/bus/usb/003/002 — is a trap. USB device nodes are character major 189 with minor (bus-1)*128 + (devnum-1), and devnum increments every time you replug. Pin the path and it breaks the first time someone moves the cable.
Worse, this scanner reports no serial number at all:
lsusb -v -d 04c5:10e0 | grep -iE "iSerial|iManufacturer|iProduct"
# iManufacturer 0
# iProduct 0
# iSerial 0
So udev can only match on vendor and product. That's still a signature rather than a location, which is what matters:
# /etc/udev/rules.d/99-fujitsu-scanner.rules
SUBSYSTEM=="usb", ATTR{idVendor}=="04c5", ATTR{idProduct}=="10e0", MODE="0666", TAG+="uaccess"
MODE="0666" isn't laziness — in an unprivileged container the bind-mounted node appears as nobody:nogroup, so only the "other" permission bits apply, and SANE needs write access, not just read.
Then in the container config, wildcard the minor rather than naming one:
lxc.cgroup2.devices.allow: c 189:* rwm
lxc.mount.entry: /dev/bus/usb dev/bus/usb none bind,optional,create=dir
That's replug-agnostic by construction. Incidentally the 106146 in the SANE device name fujitsu:fi-5120Cdj:106146 comes from a SCSI INQUIRY by the backend, not the USB descriptor — it's identical on every host, so don't try to build a udev rule around it.
"Document feeder jammed" does not mean what I thought
Here's the one that matters. I'd inherited a note — from my own earlier session, so I've only myself to blame — stating that an empty ADF reports sane_start: Document feeder jammed, and that this is the normal end-of-batch signal to ignore.
It isn't. Testing both conditions properly:
| Condition | Message | rc | double-feed |
error-code |
|---|---|---|---|---|
| Hopper genuinely empty | Document feeder out of documents |
7 | no | 0 |
| Double feed | Document feeder jammed |
6 | yes | 85 |
The scanner's own 7-segment display alternates U and 2 during the second case — U2 being Fujitsu's multifeed code, classed as a "temporary error" the operator can clear (per the fi-5120C Operator's Guide).
So the note told any future reader to treat the double-feed signal as routine and carry on. Anyone following it would silently discard exactly the failure they were most worried about. Given the whole point was "put thirty sheets in and trust it", that's a fairly comprehensive own goal.
Arming double-feed detection actually takes two steps
While we're here: --df-action Stop is widely described as the switch that turns on double-feed detection. It isn't, quite. It activates the detector options — they change from [inactive] to [no] — but every individual detector still defaults to off:
scanimage -d fujitsu:fi-5120Cdj:106146 --df-action Stop -A | grep df-
# --df-action Default|Continue|Stop [Stop]
# --df-skew[=(yes|no)] [no] <- active, but OFF
# --df-thickness[=(yes|no)] [no] <- the ultrasonic one, also OFF
# --df-length[=(yes|no)] [no]
You need both:
scanimage -d fujitsu:fi-5120Cdj:106146 \
--df-action Stop --df-thickness=yes --df-length=yes --df-skew=yes \
--source "ADF Duplex" --mode Color --resolution 300 \
--page-width 210 -x 210 --page-height 297 -y 297 \
--format=tiff --batch=page_%03d.tiff --batch-print
And note the =. --df-thickness yes with a space prints argument without option: 'yes' and still exits 0, so a script using the space form sails on believing detection is armed when nothing is. That one cost me a while.
With all three armed and two sheets deliberately stuck together, it fired at page 15 of a stack, exactly as advertised. It works, and it always did — it just needed asking properly.
While I'm on flags: set both page dimensions. Plenty of examples set only the height, at which point the width falls back to the scanner's maximum and every A4 page is quietly overscanned to US Letter width. Mine were 2550 px wide when they should have been 2480.
scanbd, and the 25 seconds that nearly broke the whole idea
For the button itself I used scanbd, the scanner button daemon. It's the right tool: the scanner has no interrupt endpoint (bNumEndpoints 2, both Bulk) and SANE has no event API, so polling is the only mechanism available at either layer — no point writing my own.
Two things to know before you start.
One: the poll interval must be shorter than a button press. A real press on this scanner measured 421 ms. scanbd ships with timeout = 500 (milliseconds), which is longer, so presses get missed intermittently. Set it to 150.
Two: the shipped global actions will bite you. /etc/scanbd/scanbd.conf defines scan, email, copy and preview actions all pointing at a test.script that doesn't exist, and they fire in addition to your device-section actions. So every press produced access/stat/execlp: No such file or directory in the log — and if you'd helpfully pointed them at your real script instead, you'd get two scans per press. I neutralised their filters rather than repointing them.
Also, and this took me an embarrassingly long time to spot: scanbd passes SCANBD_ACTION as the action name from your config, not the SANE option name. Name your action scanbutton and that's what arrives, not scan.
But the real problem was latency. Press the button, and roughly fifteen to twenty seconds of nothing, then the motors would start. Long enough to type a message to someone about it, which is exactly what I did.
I very nearly went tuning poll intervals. Fortunately I measured instead:
14:04:11.381 scanbd trigger
14:04:11.543 scanimage invoked +162 ms
14:04:36.761 scanimage's first output +25.2 SECONDS
162 milliseconds of software, then twenty-five seconds of nothing. Narrowing it further:
14:04:11.554 scanbm started
<- 25.0 s of absolutely nothing
14:04:36.579 saned starting up
The cause turns out to be documented, if you know what to search for: scanbd's stop_sane_threads() waits for the active action to finish before releasing its SANE threads — src/scanbd/sane.c:1192, logging stop_sane_threads: an action is active, waiting. Your action script is that active action, and it's the thing that needs the scanner. A textbook self-deadlock, which only clears when scanbm's timeout expires. The upstream issue is open with no fix.
The cure is to make the action script get out of the way immediately and do the real work detached:
setsid env SCAN_DEVICE="$USE" SCAN_NAME="$NAME" \
/bin/bash -c '/usr/local/bin/scan-capture >>"'"$LOG"'" 2>&1' \
</dev/null >/dev/null 2>&1 &
log "returned immediately so scanbd can release the device (pid $!)"
exit 0
Result:
| Before | After | |
|---|---|---|
| scanbm start to saned start | 25,008 ms | 153 ms |
| Trigger to "Scanning page 1" | 25,506 ms | 687 ms |
A 163x improvement from reading an open bug report rather than turning knobs. The trade-off is real and worth stating: the action script now exits 0 immediately, so scanbd's exit status no longer reflects the scan. The outcome has to be read from your own log.
I'd tried two other theories first and both were wrong, which is the useful part. Lamp warm-up from power-save? power-save read no, and the transitions in the log all coincided with scanbd restarts rather than anything sleeping. A missing /run/scanbd.pid (scanbd running with -f under Type=simple never writes one)? I created it by hand and timed it again: 25.11 s cold, 0.01 s warm — completely unchanged.
The Raspberry Pi wrinkle
I wanted the same thing working whether the scanner is plugged into the server or into a Raspberry Pi, so I could move the cable and not think about it.
The Pi (221 MB RAM, single core) couldn't assemble PDFs. img2pdf on four A4 300 dpi colour TIFFs got killed unfinished after 5 minutes 17 seconds, having climbed to 73 MB RSS with swap already in use. I'd written the Pi off as capture-only and started designing a handoff to the server.
Then I tested the obvious alternative rather than assuming, and it turns out img2pdf is a Python tool that decodes whole images into memory, whereas libtiff's utilities are C and stream:
| Step | Time | Peak RSS | Output |
|---|---|---|---|
tiffcp 4 files to multipage TIFF |
19.2 s | 27 MB | 104,483,064 B |
tiff2pdf to PDF |
18.8 s | 94 MB | 104,401,044 B, qpdf --check clean |
38 seconds on hardware that couldn't run the Python tool at all. So no handoff and no second pipeline — one script that picks its assembler on MemTotal:
if [ "$ASSEMBLER" = "auto" ]; then
TOTAL_MB=$(awk '/MemTotal/{print int($2/1024)}' /proc/meminfo)
if [ "${TOTAL_MB:-0}" -lt 1024 ]; then ASSEMBLER="tiff2pdf"; else ASSEMBLER="img2pdf"; fi
fi
An important caveat I only found once it ran in anger. That 38 seconds was measured with the
TIFFs on local disk. In the deployed setup the Pi spools to a CIFS share (its SD card can't hold a
big job), and over the network the same two sheets took 4 minutes 9 seconds — tiffcp and
tiff2pdf each read and write the whole ~104 MB across the wire on a single core. tiff2pdf also
writes an uncompressed PDF, so the output was 104 MB where img2pdf produced 59 MB for the same
pages.
So the honest version: tiff2pdf rescues a small-RAM machine that otherwise cannot assemble at
all, but if you spool it over a network share, budget minutes rather than seconds, and reach for
tiffcp -c lzw / tiff2pdf -z before you reach for the share.
Delivery: use the API, not a watched folder
Paperless-ngx has a consume directory, and dropping a PDF in it works. I started there and moved to the REST API for one reason: a file write tells you the write succeeded and nothing else. The API gives you a real answer.
curl -sS -X POST -H "Authorization: Token $TOKEN" \
-F "document=@$PDF" -F "title=$NAME" \
"$API/api/documents/post_document/"
# -> "2c0cceef-6426-484b-99da-374e96133131"
Poll that task ID and you get the outcome and the document number:
curl -sS -H "Authorization: Token $TOKEN" "$API/api/tasks/?task_id=$TASK"
# "status": "SUCCESS", "result": "Success. New document id 16 created", "related_document": "16"
One caveat worth flagging: the upstream API docs describe result_data.document_id. The deployed 2.20.15 returns related_document and a result sentence. My first attempt read None for the document ID because I'd trusted the documentation over the installation. Check what your version actually returns.
It also sidesteps a whole class of problem — an unprivileged LXC can't mount CIFS at all (mount error(1): Operation not permitted), so the shared-folder route needs a host mount, a bind mount, a uid map, and a mountpoint assertion that a bind mount would satisfy falsely. None of which exists if you just POST the file.
One that would have bitten silently
While chasing something else I fed Paperless a 1.38 GB PDF (22 pages at 600 dpi — don't). It OOM-killed the Celery worker. Fair enough. What isn't fair enough is that paperless-task-queue.service ships with Restart=no, so the worker stayed dead for an hour and every subsequent scan queued as PENDING forever, with no error surfaced anywhere a user would think to look.
# /etc/systemd/system/paperless-task-queue.service.d/restart.conf
[Service]
Restart=on-failure
RestartSec=10
On which note: 600 dpi is a trap for document scanning. It's four times the data for no OCR benefit on text, the assembly is single-threaded and slow, and Paperless compresses the result by about 95% anyway — you spend gigabytes to produce megabytes. 300 dpi throughout.
Where it ended up
Press the Scan button, and roughly 690 milliseconds later the ADF starts feeding. Capture runs at about 7 to 9 seconds per sheet at 300 dpi colour A4 duplex, then it assembles, validates with qpdf --check, uploads, and polls until Paperless confirms the document number. Double-feed detection is armed, so a mis-grab stops the job rather than quietly losing a page.
Still on the list: proving the double-feed stop behaves identically over the network backend (it's verified over direct USB), and deciding what the appliance should do after a double feed — discard the partial document, or resume and append. That's a user-interface question rather than a technical one, and I haven't answered it yet.
References
- Paperless-ngx API — document management system;
post_documentandtasksendpoints (v2.20.15 here) - scanbd 1.5.1 — scanner button daemon
- scanbm(8) — the proxy that asks scanbd to release the device
- mdengler/scanbd issue #3 —
stop_sane_threadsdelay after button press; the 25-second cause, still open - sane-fujitsu(5) — SANE backend for Fujitsu fi-series; the full option list, and notably no eject capability
- SANE project 1.2.1 —
sane_control_optionis the whole event story; there isn't one - libtiff —
tiffcpandtiff2pdf, the streaming C tools that saved the Pi - img2pdf 0.4.4 / 0.6.1 — excellent, but memory-hungry by design
- qpdf 11.3.0 / 12.2.0 —
--checkbefore you deliver; a PDF that opens is not a PDF that's valid - Fujitsu fi-5120C Operator's Guide — error indications; U2 is multifeed, and fanning the stack really does help
- Proxmox VE Linux Container — unprivileged LXC device passthrough
I hope this saves someone the twenty-five seconds. Repeatedly. Enjoy!