The Brother DCP-L2540DW is a monochrome laser printer with a flatbed scanner and an ADF (automatic document feeder). The front panel has a single Scan button. Pressing it opens a menu where you can select “Scan to PC”, and from there you choose one of four destinations: Image, OCR, Email, or File. The menu also has up/down navigation, but no other scan options are exposed — this is presumably where “Scan to FTP” would appear if the firmware actually supported it. This is a writeup of getting push-scan working on a headless Debian 12 LXC container running Paperless-ngx, including duplex collation for double-sided documents.
Every piece of Brother documentation — the manual, the spec sheet, support articles — says this printer can scan to FTP. It doesn’t. The feature simply does not exist in the firmware or web UI. The web UI has no scan configuration at all. The scan menu on the front panel only shows “Scan to PC” — there is no “Scan to FTP” or “Scan to Network” option, despite what the documentation claims. The scan buttons only work via Brother’s push-scan protocol, which requires a daemon running on a PC.
If you’re shopping for a network scanner that scans to a share without client software, this isn’t it. But if you’re willing to run a daemon, it works well.
Brother provides two packages for Linux scanning:
The daemon listens on UDP port 54925. When you select a scan
destination on the printer (Image, OCR, Email, or File), it sends a
notification to the daemon, which calls one of four scripts in
/opt/brother/scanner/brscan-skey/script/:
scantofile.sh — "Scan to File" button
scantoimage.sh — "Scan to Image" button
scantoocr.sh — "Scan to OCR" button
scantoemail.sh — "Scan to Email" button
Each script receives the SANE device string as $1. The
default scripts try to open a GUI, which is useless on a headless
server. We replace them with our own.
After installing brscan4, register the printer:
brsaneconfig4 -a name=DCP-L2540DW model=DCP-L2540DW ip=192.168.86.115This is where it gets annoying. There are multiple SANE backends that can talk to Brother scanners:
When a push-scan session starts, the printer locks into a brscan4
session. If the airscan backend tries to probe the scanner at the same
time, you get Error during device I/O and the scanner shows
“Connecting to PC” and hangs. The fix is to disable the airscan backend
in /etc/sane.d/dll.conf or just not install it.
But here’s the real kicker: **you can’t use system
scanimage for push-scan at all.** The SANE device
handle passed by brscan-skey is only valid for Brother’s own binary.
Brother ships a separate binary at
/opt/brother/scanner/brscan-skey/skey-scanimage. This is
NOT the same as system scanimage from sane-utils. System
scanimage fails with “Invalid argument” on every flag when given a
push-scan device handle.
The binary’s flags are different from system scanimage. We found them
by running strings on it:
strings /opt/brother/scanner/brscan-skey/skey-scanimage | grep -E '^\-\-'Key flags:
--device-name SANE device string (passed as $1 by brscan-skey)
--resolution DPI (integer)
--mode "Black & White" | "24bit Color" | "True Gray"
--source ADF_C (auto document feeder) | FB (flatbed)
--size Letter | A4 | Legal
--outputfile output path (TIFF format)
--duplex hardware duplex (doesn't work on this model)
Important behaviors:
--source ADF_C scans all pages in the feeder into a
single multi-page TIFF--source ADF_C **hangs forever** if the
ADF tray is empty — no timeout, no error--mode "Black & White" is not documented anywhere;
found via stringsRather than maintaining four separate scripts, we use a single Jinja2 template deployed by Ansible:
#!/bin/bash
# {{ scan_comment }}
DEVICE="$1"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
TMPDIR=$(mktemp -d /tmp/scan-XXXXXX)
OUTFILE="{{ paperless_consume_dir }}/scan_${TIMESTAMP}.pdf"
SCANIMAGE=/opt/brother/scanner/brscan-skey/skey-scanimage
sleep 1
$SCANIMAGE \
--device-name "$DEVICE" \
--resolution {{ scan_resolution }} \
{% if scan_mode is defined %}
--mode "{{ scan_mode }}" \
{% endif %}
--source {{ scan_source }} \
--size Letter \
--outputfile "${TMPDIR}/scan.tif" 2>/dev/null
if [[ ! -s "${TMPDIR}/scan.tif" ]]; then
echo "{{ scan_label }}: no pages scanned" >&2
rm -rf "$TMPDIR"
exit 1
fi
TMPOUT=$(mktemp /tmp/scan-pdf-XXXXXX.pdf)
img2pdf "${TMPDIR}/scan.tif" -o "$TMPOUT"
chown 1000:1000 "$TMPOUT"
mv "$TMPOUT" "$OUTFILE"
rm -rf "$TMPDIR"
{% if scan_duplex_collate | default(false) %}
/usr/local/bin/scan-duplex-collate.sh "$OUTFILE" "{{ paperless_consume_dir }}"
{% endif %}
Each button gets its own instantiation with different variables:
- name: Install scan-adf-bw script
template:
src: ../templates/brother/scan.sh.j2
dest: /usr/local/bin/scan-adf-bw.sh
mode: '0755'
vars:
scan_comment: "ADF multi-page B&W scan"
scan_label: scan-adf-bw
scan_resolution: 300
scan_mode: "Black & White"
scan_source: ADF_C
scan_duplex_collate: trueThe printer always defaults to Image when you press the scan button, so we put the most common job there. The final mapping:
Image → B&W ADF 300dpi (daily documents, duplex)
OCR → B&W Flatbed 300dpi (single page B&W)
Email → Color ADF 300dpi (multi-page color, duplex)
File → Color Flatbed 600dpi (high-res single page)
Every scan lands in Paperless-ngx’s consume directory as a PDF, where it gets OCR’d and indexed automatically.
The DCP-L2540DW does not support hardware duplex scanning. The
--duplex flag exists in the binary but does nothing useful
on this model. So we built a software collation heuristic.
The first pass captures all front sides. The second pass captures all back sides. Because the stack was flipped, the backs come out in reverse order and upside down.
After each ADF scan, the scan script calls
scan-duplex-collate.sh. The script:
Collation uses qpdf:
# Rotate all back pages 180 degrees (they're upside down)
qpdf "$BACKS" --rotate=+180 -- "${TMPDIR}/backs_rotated.pdf"
# Interleave: front1, backN, front2, back(N-1), ...
PAGE_ARGS=()
for i in $(seq 1 "$PAGES"); do
BACK_PAGE=$(( PAGES - i + 1 ))
PAGE_ARGS+=("$FRONTS" "$i")
PAGE_ARGS+=("${TMPDIR}/backs_rotated.pdf" "$BACK_PAGE")
done
qpdf --empty --pages "${PAGE_ARGS[@]}" -- "$TMPOUT"The back pages are reversed because the ADF flips each sheet as it feeds through. You take the output stack and put it straight back on the input — no manual flipping or reordering. But the ADF’s feed-through means the stack is now effectively reversed and upside down. Page 1 of the backs PDF corresponds to the last page’s back side. The 180-degree rotation corrects for the pages being upside down.
Paperless-ngx has a consume directory that it polls every 60 seconds. The first ADF scan (fronts) gets dropped there immediately. But Paperless might consume it before the second scan (backs) completes. So the collation script copies each scan to a staging directory before Paperless can eat it. Staging files older than 60 minutes are cleaned up automatically.
All three documents — fronts only, backs only, and the collated duplex version — end up in Paperless. You can delete the partials after verifying the collated version looks right.
Pairing is tuple-based: the 1st and 2nd matching scans are paired, the 3rd and 4th are paired, and so on. A 60-minute timeout resets the sequence. This means you can scan multiple duplex documents in a row without them interfering with each other, as long as each pair has the same page count.
brscan4 — Brother SANE backend
brscan-skey — push-scan daemon
sane-utils — SANE utilities
img2pdf — TIFF to PDF conversion
qpdf — PDF page manipulation (duplex collation)
The full scan-duplex-collate.sh script, deployed via
Ansible template. Called after each ADF scan with the PDF path and
consume directory as arguments.
#!/bin/bash
# Duplex collation heuristic for ADF scans.
# Called after each ADF scan with: $1 = PDF path, $2 = consume dir.
# Tracks consecutive same-page-count scans and collates pairs.
PDF="$1"
CONSUME_DIR="$2"
STAGING_DIR="/opt/paperless/scan-staging"
STATE_FILE="/opt/paperless/scan-staging/duplex-state"
TIMEOUT=3600
mkdir -p "$STAGING_DIR"
PAGES=$(qpdf --show-npages "$PDF" 2>/dev/null)
if [[ -z "$PAGES" || "$PAGES" -lt 2 ]]; then
exit 0
fi
NOW=$(date +%s)
BASENAME=$(basename "$PDF")
# Keep a copy in staging — Paperless may consume the original
cp "$PDF" "${STAGING_DIR}/${BASENAME}"
# Read previous state: TIMESTAMP|PAGES|STAGING_PATH|SEQUENCE
if [[ -f "$STATE_FILE" ]]; then
IFS='|' read -r PREV_TS PREV_PAGES PREV_STAGING PREV_SEQ < "$STATE_FILE"
else
PREV_TS=0; PREV_PAGES=0; PREV_STAGING=""; PREV_SEQ=0
fi
AGE=$(( NOW - PREV_TS ))
if [[ "$PAGES" -eq "$PREV_PAGES" && "$AGE" -le "$TIMEOUT" ]]; then
SEQ=$(( PREV_SEQ + 1 ))
else
SEQ=1
fi
echo "${NOW}|${PAGES}|${STAGING_DIR}/${BASENAME}|${SEQ}" > "$STATE_FILE"
# Clean up staging files older than 60 minutes
find "$STAGING_DIR" -maxdepth 1 -name '*.pdf' -mmin +60 -delete 2>/dev/null
# Only collate on even sequence numbers (completed pairs)
if [[ $(( SEQ % 2 )) -ne 0 ]]; then
exit 0
fi
FRONTS="$PREV_STAGING"
BACKS="${STAGING_DIR}/${BASENAME}"
if [[ ! -f "$FRONTS" || ! -f "$BACKS" ]]; then
exit 1
fi
TMPDIR=$(mktemp -d /tmp/collate-XXXXXX)
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
# Rotate all back pages 180° (they're upside down)
qpdf "$BACKS" --rotate=+180 -- "${TMPDIR}/backs_rotated.pdf"
# Build interleaved page list: front1, backN, front2, back(N-1), ...
PAGE_ARGS=()
for i in $(seq 1 "$PAGES"); do
BACK_PAGE=$(( PAGES - i + 1 ))
PAGE_ARGS+=("$FRONTS" "$i")
PAGE_ARGS+=("${TMPDIR}/backs_rotated.pdf" "$BACK_PAGE")
done
TMPOUT=$(mktemp /tmp/scan-collated-XXXXXX.pdf)
qpdf --empty --pages "${PAGE_ARGS[@]}" -- "$TMPOUT"
chown 1000:1000 "$TMPOUT"
mv "$TMPOUT" "${CONSUME_DIR}/scan_${TIMESTAMP}_duplex.pdf"
rm -rf "$TMPDIR"