My Epson EcoTank ET-2751 is a fine printer, but its Wi-Fi interface died. The printer itself still works perfectly — only the radio is gone, and Epson wants more for a repair than a used replacement costs. The only interface left is USB, but I wanted printing and scanning from every machine in the house.
Full disclosure: this project was solved with the support of an AI assistant, and this article was written with its help as well. It did the debugging (the two SANE bugs below were neither obvious nor fun), the patch and the cross-build recipe; I did the hardware, the decisions and the testing on the real machines. Everything in here is the configuration that actually runs on the box.
The same trick as with my picture frame: a Raspberry Pi Zero 2 W with diskless Alpine Linux. The printer goes in via USB, the Pi sits on the WLAN, and both CUPS and SANE share the devices over the network. Total cost: an SD card and a USB power supply.
The base system
setup-alpine in diskless mode, WLAN via wpa_supplicant, zram for swap. Diskless means root is a tmpfs and only /etc hits the SD card when you run lbu commit — the card is read at boot and never written otherwise. Two things matter:
- The apk cache must live on the boot medium, so installed packages return after a reboot. Alpine sets up
/etc/apk/cache → /media/mmcblk0p1/cacheautomatically. - Commit early, commit often. Everything outside
lbu commitevaporates on the next reboot. I learned that the hard way, twice.
The complete package list:
alpine-base avahi busybox-mdev-openrc cups cups-client cups-filters dbus
dosfstools epson-inkjet-printer-escpr ghostscript iw openssh openssl parted
sane-backend-epsonds sane-backend-net sane-saned sane-udev sane-utils
wpa_supplicant zram-init
Printing
The driver comes from the epson-inkjet-printer-escpr package. Find the PPD id and create the queue:
| |
For LAN sharing, the relevant part of cupsd.conf:
| |
Browsing Yes plus Avahi (rc-update add avahi-daemon default) advertises the queue via mDNS, so every Linux, macOS and Windows client finds it on its own — no client configuration at all. rc-update add cupsd default, lbu commit, done.
Scanning
The scanner part needs the SANE packages (sane-utils sane-backend-epsonds sane-saned sane-udev sane-backend-net) and then hits two problems.
Problem 1: the backend doesn’t know this printer. The epsonds backend has no USB ID entry for 04b8:112a (the ET-2750/2751) — even upstream, while the sibling models ET-2700/2710 are in the table. Rebuilding isn’t necessary, though: epsonds accepts custom model entries in its config. The printer reports its own product string as PID 112A, so the first line of /etc/sane.d/epsonds.conf is:
| |
Format: profile:<hex pid>,<reported product string>,<display name>,<lut id>. The LUT id 7 is the one the ET-2700 family uses. Alpine’s dll.conf ships with every backend commented out, so epsonds (and later net) has to be appended.
Sharing it is the classic saned setup: /etc/sane.d/saned.conf gets the LAN range (10.0.0.0/20), rc-update add saned default starts it on port 6566. One Alpine-specific detail: the init script runs saned as the saned user, but under busybox-mdev the USB device nodes are root:root 660 — so addgroup saned root gives the daemon access. net.conf containing localhost is only there so the server can test itself with scanimage -L.
Problem 2: saned gets empty scans. With that setup, local scans worked, but every scan over the network produced an almost empty file. Two upstream bugs in sane-backends 1.4.0 conspire here:
- epsonds acquires the whole page lazily inside
sane_get_parameters.sane_readreturns EOF when it is called before that — and saned always polls before the parameters are fetched. The first read ends the data connection with the entire page still in the backend’s ring buffer. - Even after fixing that, any image larger than saned’s 1 MB wire buffer fails: saned’s second read hands the backend a buffer of
buffer_size - readerbytes — the 2988-byte tail sliver, which is smaller than one 7632-byte scan line at 300 dpi. The backend computes “0 lines”, reports EOF, and the scan ends with a perfectly formed PNG containing almost no pixels.
Both are fixed in sane_read: it pulls the page itself when needed (via the already existing get_next_image(), which is a no-op once acquisition happened) and stashes one full line when the caller’s buffer is smaller, serving it in parts:
| |
The full patch and a build recipe live in my nix repository (packages/sane-backends-epsonds-saned-fix). I cross-compiled sane-backends 1.4.0 with Nix for aarch64-musl — the one Alpine-specific catch is that the sonames must match Alpine’s libraries (libxml2.so.2, libjpeg.so.8, libc.musl-aarch64.so.1), which means pinning libxml2 2.13 and the jpeg8-ABI turbo build. The result is a single libsane-epsonds.so.1.4.0 that replaces the distro file. Local scans behave exactly as before; a 150 dpi A/B comparison of local vs. network scanning came out pixel-identical.
Since the patched file lives outside /etc, it needs one extra line to survive reboots: lbu include /usr/lib/sane/libsane-epsonds.so.1.4.0.
Reliability
A headless machine with a flaky USB controller (the Zero 2 W’s dwc_otg) wants a watchdog: bcm2835_wdt in /etc/modules and the busybox watchdog service (-t 15). If the kernel ever hangs, the hardware resets it within fifteen seconds instead of the box silently disappearing from the network until someone pulls the plug.
Clients
Linux clients need two services for printing: Avahi for mDNS discovery and cups-browsed, which watches for advertised printers and creates a local queue for each one. No local printer drivers — the server does the rendering, clients submit PDFs to it. The local cupsd stays as the queue host that GUI apps and lp talk to; it is socket-activated and only runs while something actually prints.
On NixOS (my setup):
| |
After that, http://localhost:631/printers shows the server’s queue (auto-created, something like ET2751 @ alpine-printer) and every print dialog offers it. On classic distros the same three pieces are cups, avahi-daemon and cups-browsed enabled as services, plus one line 10.0.3.35 in /etc/sane.d/net.conf for scanning.
One rule for scanning: never hardcode the device string. It contains the USB node number (net:10.0.3.35:epsonds:libusb:001:002), and that number changes whenever the Pi reboots. Plain scanimage without -d uses the only configured device anyway.