TL;DR: s1500d is a tiny Rust daemon that monitors the Fujitsu ScanSnap S1500 via direct USB and runs your script when you press the scan button or insert paper. One USB command per poll cycle, no SANE stack, no scanbd. Open the lid, press the button, get a PDF.
the problem
The ScanSnap S1500 is a fantastic color duplex document scanner. It launched in 2009 and has been out of production for years, but I’ve had mine since 2013 and it still works great. We use it as the front door to our paperless household (a well-trodden path), paired with paperless-ngx and some LLM postprocessing via Modal, all running on an Arch Linux homelab server.
On Mac and Windows, the software situation is bleak — Fujitsu’s current
ScanSnap Home doesn’t
support it at all, and they
discontinued ScanSnap Manager
(the legacy software that did) in November 2024. You can still find old ScanSnap
Manager installers floating around the web, and apparently they still work, but
it’s not a great long-term bet. On Linux, none of that matters — SANE’s
fujitsu backend handles scanning just fine. The hard part is the “one-touch”
workflow: you want to press the physical scan button and have something happen
automatically, without a GUI open and waiting.
The usual answer is scanbd, a general-purpose scanner button daemon. I’ve had mixed results with scanbd — over different OSes and installations, I’ve usually gotten it to work, but I struggled with it. Sometimes I couldn’t get button-press detection to work, but it could trigger on paper feed. Sometimes it would wait for nearly a minute before actually beginning to scan the document. I’d followed the docs and the ever-amazing Arch Wiki, and it’s ultimately probably user error. But now there’s Claude Code, and I wanted to see if I could get something that works more consistently for me.
I asked Claude to help me figure out how the scanner was actually communicating
with the computer. It wrote a diagnostic script
(explore.py)
that captured USB traffic using
termshark (a terminal UI for Wireshark),
then guided me through a protocol — insert paper, remove paper, press the
button, release the button — recording which bits changed at each step. Between
that and reading the SANE fujitsu backend source, we reverse-engineered the
USB protocol. It’s actually pretty simple: one command (GET_HW_STATUS), 12
bytes of response, a few status bits to decode. Full details in the
protocol reference.
scanbd takes a different approach — it loads the full SANE stack, opens a
connection to the backend, and polls by reading SANE options, about 25 SCSI
commands per cycle. It needs a scanbm proxy to coordinate device access
between polling and scanning. Those are reasonable decisions if you’re building
something that supports every SANE-compatible scanner. But if you only need to
talk to one device, you can skip all of that and send the one command directly
via libusb.
That’s what s1500d does. The tradeoff is clear: it only works with the ScanSnap S1500 (and potentially other ScanSnap models with compatible protocols — I’ve only tested the S1500). If you have an S1500 and want something minimal that just works, read on. Or if you’re interested in a kind of template for using a coding agent to reverse-engineer a USB protocol and build a bespoke driver for some other piece of hardware, this might be a useful case study.
installation
Arch Linux (AUR)
I published an AUR package for my own convenience, but in case one of the other six people running an S1500 on Arch Linux reads this:
paru -S s1500d
This installs the binary, systemd unit, udev rules, and example config/handler.
from source
You’ll need libusb and a Rust toolchain:
# Arch/CachyOS
pacman -S libusb
# Debian/Ubuntu
apt install libusb-1.0-0-dev
# Fedora
dnf install libusb1-devel
Then either:
# Install via cargo
cargo install --path .
# Or via make (installs systemd unit, udev rules, config, etc.)
make release
sudo make install
See INSTALL.md for the full details.
quick start: seeing events
The simplest way to try s1500d is to just run it with no arguments. Open the scanner lid (which powers it on via USB), then:
s1500d
You’ll see events logged to stderr as you interact with the scanner:
| Event | Meaning |
|---|---|
device-arrived |
Scanner lid opened (USB device appeared) |
device-left |
Scanner lid closed (USB device removed) |
paper-in |
Paper inserted into feeder |
paper-out |
Paper removed from feeder |
button-down |
Scan button pressed |
button-up |
Scan button released |
To actually do something with these events, pass a handler script:
s1500d /path/to/handler.sh
The handler receives the event name as $1. Here’s a minimal example:
#!/bin/bash
EVENT="$1"
PROFILE="${2:-}"
case "$EVENT" in
scan)
logger -t s1500d "Scan gesture: profile=$PROFILE"
# Your scan logic here — scanimage is safe to call,
# s1500d has released the USB device.
;;
paper-in)
logger -t s1500d "Paper detected"
;;
button-down)
logger -t s1500d "Scan button pressed (legacy mode)"
;;
device-arrived)
logger -t s1500d "Scanner lid opened"
;;
device-left)
logger -t s1500d "Scanner lid closed"
;;
*)
logger -t s1500d "Event: $EVENT"
;;
esac
One important detail: s1500d releases the USB device before calling your
handler. This means scanimage and other SANE tools can claim the scanner
cleanly — no fighting over the device handle.
scan to PDF
The
contrib/handler-scan-to-pdf.sh
script is a practical handler that scans all pages in the ADF to a timestamped
PDF using scanimage and img2pdf. Here’s how it works:
#!/bin/bash
SCAN_DIR="${SCAN_DIR:-$HOME/Scans}"
EVENT="$1"
PROFILE="${2:-scan}"
case "$EVENT" in
scan)
mkdir -p "$SCAN_DIR"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
OUTFILE="$SCAN_DIR/${PROFILE}_${TIMESTAMP}.pdf"
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
logger -t s1500d "Scanning: profile=$PROFILE → $OUTFILE"
scanimage \
--device-name="fujitsu:ScanSnap S1500:*" \
--source="ADF Duplex" \
--mode=Color \
--resolution=300 \
--format=tiff \
--batch="$TMPDIR/page_%04d.tiff" \
--batch-count=0 \
2>/dev/null
PAGES=("$TMPDIR"/page_*.tiff)
if [ ${#PAGES[@]} -eq 0 ] || [ ! -f "${PAGES[0]}" ]; then
logger -t s1500d "No pages scanned"
exit 1
fi
img2pdf "${PAGES[@]}" -o "$OUTFILE"
logger -t s1500d "Saved $OUTFILE (${#PAGES[@]} pages)"
;;
device-arrived)
logger -t s1500d "Scanner ready"
;;
device-left)
logger -t s1500d "Scanner closed"
;;
esac
The pipeline is: scanimage pulls all pages from the ADF as TIFFs into a temp
directory, then img2pdf combines them into a single PDF. The profile name
(from gesture detection, described below) becomes the filename prefix, so you
can tell at a glance what kind of scan it was.
You’ll need sane and img2pdf installed:
# Arch
pacman -S sane img2pdf
# Debian/Ubuntu
apt install sane-utils img2pdf
# Fedora
dnf install sane-backends img2pdf
configuration
Running s1500d with -c enables config mode, which reads a TOML file:
s1500d -c config.toml
Here’s a full example:
handler = "/path/to/your/handler.sh"
gesture_timeout_ms = 400
log_level = "info"
[profiles]
1 = "standard"
2 = "legal"
3 = "photo"
config keys
| Key | Required | Default | Description |
|---|---|---|---|
handler |
yes | — | Path to the script called on events |
gesture_timeout_ms |
no | 400 |
How long to wait (in ms) for additional button presses before dispatching a gesture |
log_level |
no | "info" |
Log verbosity: error, warn, info, debug, trace. The RUST_LOG environment variable overrides this if set. |
profiles |
no | (empty) | Map of press count → profile name (see below). Profile names are arbitrary labels — your handler script decides what they mean. |
events in config mode
In config mode, your handler receives these events as $1:
| Event | $2 |
When it fires |
|---|---|---|
scan |
profile name | Button gesture completed (press count mapped to a profile) |
paper-in |
— | Paper inserted into feeder |
paper-out |
— | Paper removed from feeder |
device-arrived |
— | Scanner lid opened (USB device appeared) |
device-left |
— | Scanner lid closed (USB device removed) |
gesture detection
Instead of passing raw button-down/button-up events, config mode counts
rapid button presses within the gesture_timeout_ms window and maps the count
to a named profile via the [profiles] table.
Press the button once, wait 400ms, and your handler gets called with
scan standard. Press twice quickly and it gets scan legal. Three times for
scan photo. Unmapped press counts are logged and ignored.
The config above maps three presses, but your handler can support as many profiles as you like — you just map the ones you use most often to button gestures. Here are some natural options for reference:
case "$PROFILE" in
standard)
scanimage --source="ADF Duplex" --mode=Color \
--resolution=300 --format=tiff \
--batch="$TMPDIR/page_%04d.tiff" --batch-count=0
;;
legal)
scanimage --source="ADF Duplex" --mode=Color \
--resolution=300 --page-width=215.872 --page-height=355.6 \
-x 215.872 -y 355.6 --format=tiff \
--batch="$TMPDIR/page_%04d.tiff" --batch-count=0
;;
a4)
scanimage --source="ADF Duplex" --mode=Color \
--resolution=300 --page-width=210 --page-height=297 \
-x 210 -y 297 --format=tiff \
--batch="$TMPDIR/page_%04d.tiff" --batch-count=0
;;
photo)
scanimage --source="ADF Front" --mode=Color \
--resolution=600 --format=tiff \
--batch="$TMPDIR/page_%04d.tiff" --batch-count=0
;;
standard-bw)
scanimage --source="ADF Duplex" --mode=Lineart \
--resolution=300 --format=tiff \
--batch="$TMPDIR/page_%04d.tiff" --batch-count=0
;;
standard-gray)
scanimage --source="ADF Duplex" --mode=Gray \
--resolution=300 --format=tiff \
--batch="$TMPDIR/page_%04d.tiff" --batch-count=0
;;
esac
The scan-to-PDF handler above uses the profile as a filename prefix. You could extend it with a case block like this to vary the scan parameters per profile.
running as a systemd service
For a proper always-on setup, you’ll want a udev rule for non-root USB access and a systemd unit to keep the daemon running.
udev rule
Install the udev rule so s1500d can access the scanner without root:
sudo cp contrib/99-scansnap.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules
The rule itself is simple — it matches the S1500’s USB vendor/product ID and opens permissions:
SUBSYSTEM=="usb", ATTR{idVendor}=="04c5", ATTR{idProduct}=="11a2", MODE="0666", TAG+="uaccess"
systemd unit
[Unit]
Description=ScanSnap S1500 event daemon
Documentation=https://github.com/mmacpherson/s1500d
After=local-fs.target
[Service]
Type=simple
ExecStart=/usr/bin/s1500d -c /etc/s1500d/config.toml
Restart=always
RestartSec=5
# Hardening
NoNewPrivileges=true
ProtectHome=true
[Install]
WantedBy=multi-user.target
Copy it into place and enable:
sudo cp contrib/s1500d.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now s1500d
If you installed via the AUR package, the unit and udev rule are already in the right places — just enable the service.
diagnosing hardware
If things aren’t working, the --doctor flag runs an interactive hardware check that walks you through each sensor:
s1500d --doctor
It’ll ask you to open the lid, insert paper, press the button, and so on — confirming that the daemon can see each event. Useful for verifying that USB permissions are set up correctly and the scanner is responding as expected.
under the hood
The S1500 uses a vendor-specific USB protocol (class FF:FF:FF) with SCSI commands wrapped in a 31-byte Fujitsu envelope. The daemon sends a GET_HW_STATUS command (SCSI opcode 0xC2) every 100ms and decodes the 12-byte response to detect button presses and paper presence. State transitions are edge-triggered — the handler only fires when something changes.
Door state isn’t in the status response at all. Opening the ADF lid powers the scanner on (USB enumeration), closing it powers off (USB disconnect). So the daemon has two loops: an outer one watching for USB connect/disconnect, and an inner one polling GET_HW_STATUS while the device is present.
The protocol was reverse-engineered from USB captures and the SANE fujitsu backend source, then empirically verified with a physical scanner. The full details — including a SANE bit-map discrepancy I found during testing — are in the protocol reference.
references & prior art
s1500d exists because other people documented their ScanSnap-on-Linux setups and I could build on their work. These are the posts that informed the project:
- Virantha Ekanayake — One-Touch Scan-To-PDF With ScanSnap S1500 on Linux (2014) — the original scanbd + S1500 walkthrough
- Kevin Liu — Automatic Scanning on Linux with the ScanSnap S500M (2019) — scanbd setup for a different ScanSnap model
- Neil Brown — Scanning to Debian 12 with ix500 (2024) — scanbd on Debian with the ix500
- J.B. Rainsberger — Use Your ScanSnap Scanner with Linux — general ScanSnap + Linux guidance
Going paperless at home:
- Tools and Toys — Setting Up and Maintaining a Paperless Home and Office — the classic ScanSnap-centric paperless guide
- DocumentSnap — Brooks Duncan’s site dedicated to going paperless, including the Unofficial ScanSnap Setup Guide
- Techno Tim — Self-Hosted Paperless-ngx + Local AI — modern Docker-based setup with local AI for OCR/classification
- Akash Rajpurohit — Paperless-ngx: Self-hosted document management that actually makes sense
- Redeeming Productivity — The Ultimate Guide to Going Paperless at Home
And the tools this project depends on or relates to:
- SANE project — Scanner Access Now Easy, the Linux scanning framework
- scanbd — the general-purpose scanner button daemon
- sane-backends fujitsu — the SANE backend that handles Fujitsu scanners (and where I found the USB protocol constants)
- s1500d on GitHub — source code, issues, and contributions welcome