11 August 2026

One-Button Scanning to Paperless-ngx with a Fujitsu fi-5120C and scanbd

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 secondstiffcp 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_document and tasks endpoints (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 #3stop_sane_threads delay 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_option is the whole event story; there isn't one
  • libtifftiffcp and tiff2pdf, 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 — --check before 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!

07 August 2026

Claude Code: What /color Actually Does (and How to Auto-Set It at Startup)

I run two Claude Code sessions side by side — one on my personal subscription, one on a work account — and I wanted a quick visual cue to tell them apart. The obvious candidate was /color, which I assumed was the theme picker. It isn't, and the actual mechanism is a bit more interesting.

What /color does

/color sets the colour of the prompt bar — the horizontal separator lines and cursor at the bottom of the TUI — for the current session. The valid values are red, green, blue, yellow, cyan, magenta, orange, purple, pink, teal, coral, lime, navy, olive, violet, white, and default. It's session-scoped: there's no settings key, no --color launch flag, and the value isn't written to settings.json or .claude.json. It lives in an internal per-session-id store and disappears when the session does.

The resolution order is: value set by /color → colour from the active agent definition → default. For a normal (agentless) session the only lever is /color.

One quirk: /color is interactive-only. Running claude -p "/color green" returns "/color isn't available in this environment." So if you want it set automatically, you have to inject it as the initial prompt at launch.

The setup

My two accounts are launched via shell functions in ~/.bashrc. The personal account starts a persistent personal tmux session; the work account (which uses CLAUDE_CONFIG_DIR=~/.claude-work, set by a wrapper script at ~/.local/bin/claude-work) starts a work session.

One gotcha I ran into: I had alias claude='claude --chrome' in ~/.bash_aliases, which was silently breaking the function definition in interactive shells — bash expands the alias before parsing claude() {, making it invalid syntax. bash -n doesn't catch this because alias expansion only happens in interactive mode. The fix is unalias claude before the function definition and baking --chrome directly into the function.

unalias claude 2>/dev/null
claude() {
  if [ ! -t 1 ]; then
    command claude --chrome "$@"
  elif [ -n "$TMUX" ]; then
    if [ $# -eq 0 ] && [ "$(tmux display-message -p '#S' 2>/dev/null)" = "personal" ]; then
      command claude --chrome '/color green'
    else
      command claude --chrome "$@"
    fi
  else
    tmux has-session -t personal 2>/dev/null || tmux new-session -d -s personal "command claude --chrome '/color green'"
    tmux set-option -t personal status-style "bg=colour2,fg=black"
    tmux attach-session -t personal
  fi
}
claude-work() {
  if [ ! -t 1 ]; then
    command claude-work "$@"
  elif [ -n "$TMUX" ]; then
    if [ $# -eq 0 ] && [ "$(tmux display-message -p '#S' 2>/dev/null)" = "work" ]; then
      command claude-work '/color red'
    else
      command claude-work "$@"
    fi
  else
    tmux has-session -t work 2>/dev/null || tmux new-session -d -s work "command claude-work '/color red'"
    tmux set-option -t work status-style "bg=colour1,fg=white"
    tmux attach-session -t work
  fi
}

The logic: non-interactive → pass straight through. Inside the named tmux session with no arguments → inject the colour as the initial prompt. Outside tmux → create the session detached if it doesn't exist, set the tmux status bar colour, then attach. Arguments are passed through unchanged so the prompt slot is never clobbered.

The injection works because /color is declared immediate in the CLI's command registry, so it dispatches before anything is sent to the model. Verified with a detached pane capture:

tmux new-session -d -s ctest "command claude --chrome '/color green'"
sleep 10
tmux capture-pane -t ctest -p
tmux kill-session -t ctest

Output included Session color set to: green before the first prompt appeared.

What it looks like

The before — no colour, no tmux:

Before: uncoloured Claude Code session

Personal subscription — green Claude borders, green tmux status bar:

Personal session: green

Work account — red Claude borders, red tmux status bar:

Work session: red

The /color feature colours the horizontal separator lines around the prompt area (visible in the Claude TUI itself). The tmux status bar at the very bottom is a separate thing — it appears because the sessions now live inside named tmux sessions, and its colour is set independently with tmux set-option ... status-style. They happen to match, which is the point.

Caveats

There's no native way to set a per-account default colour in Claude Code v2.1.187 — no settings key, no CLI flag. The startup-injection approach covers the common cases, but if you pass a prompt directly (claude "do X"), the injection is deliberately skipped. Run /color green manually if needed.

The tmux set-option call runs every time you launch from outside tmux, so reattaching to an existing session will re-apply the colour — which is useful if you've changed it mid-session.

References

  • Claude Code v2.1.187 — the CLI and TUI
  • tmux 3.4 — new-session, has-session, set-option status-style, attach-session

Ta ta for now and I hope you found this helpful.