# Push-Scan with a Brother DCP-L2540DW on Linux

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.

## The FTP scan myth

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.

## Getting brscan-skey working

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.

### Registration

After installing brscan4, register the printer:

brsaneconfig4 -a name=DCP-L2540DW model=DCP-L2540DW ip=192.168.86.115

## The SANE backend problem

This 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.

## skey-scanimage: the binary you actually need

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:

## The scan script template

Rather 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: true

## Button mapping

The 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.

## Duplex collation without hardware duplex

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 workflow

  1. Place the stack face-up in the ADF. Press Image (or Email).
  2. Take the output stack — don’t reorder it — put it straight back on the ADF.
  3. Press the same button again.

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.

### The collation script

After each ADF scan, the scan script calls scan-duplex-collate.sh. The script:

  1. Counts the pages in the new PDF.
  2. Checks a state file for the previous scan’s page count and timestamp.
  3. If the page counts match and less than 60 minutes have passed, it increments a sequence counter.
  4. On even sequence numbers (a completed pair), it collates.

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.

### Staging

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 logic

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.

## Dependencies

brscan4          — Brother SANE backend
brscan-skey      — push-scan daemon
sane-utils       — SANE utilities
img2pdf          — TIFF to PDF conversion
qpdf             — PDF page manipulation (duplex collation)

## Appendix: duplex collation script

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"