Guerilla Ontology    Archive    Feed    About

A Button-Press Receipt Scanner for Paperless

A Button-Press Receipt Scanner for Paperless

Not so long ago, I got my hands on an Epson ES-50 document scanner. My main interest was for a quick and easy way to scan receipts and upload them to my self-hosted paperless-ng instance. My eventual goal is to be able to correlate the receipts to financial transactions so that I can get itemized-detail of my purchases, but that’s a tale for another time.

At first, this project seemed easy. Indeed, it could have been. I quickly found that the ES-50 scanner didn’t require any special drivers or libraries to scan documents on Linux. It’s a USB-powered device, so it was pretty much plug-‘n’-play. I just entered a scanimage command and the scanner would pull the receipt through its feeder and a new image would appear in my filesystem.

Getting that image into paperless-ng wasn’t too hard either; unsurprisingly, uploading documents is one of paperless’ primary functions, so it had first-class API support. All I needed was an API-token and a curl command. Wrapping the whole thing in one script and setting it as a shell alias was the work of one afternoon.

It could have been that simple. But then, there was the button.

The Button

The Epson ES-50 scanner has a little button on the scanner itself. Presumably, on Windows, pressing this button allows you to trigger a scan of a document from the scanner itself. On Linux, no such functionality was supported out-of-the-box. It’s one thing to support a peripheral as a scanner, but supporting a peripheral as a scanner and recieving input events from some vendor-specific device button? Now that’s a tall order.

“It works fine,” I told myself. One command from the commandline was all I needed to scan a receipt. It was easy. It wasn’t even that inconvenient.

But it could have been better.

I spent a few afternoons playing around with the scanner myself. I set up a program that could capture raw input events directly from the USB device to see if there was any way I could detect the button press and trigger my own events in response. As I recall, the issue I ran into was that although it was possible to detect the button press, it required an exclusive lock on the input device from the program performing the detection. This unfortunately prevented the scanner from actually being used as a scanner, since SANE couldn’t read from it.

At that point, I was at least satisfied that matter was impossible. I had probably wasted far more time out of my life experimenting with the damn scanner than being able to trigger scans from the button could have possibly saved me, but that was pretty much par for the course with homelab stuff anyways.

es2button

I endured the agony of knowing a task I perform for only about twenty minutes once a month could theoretically, if not practically, be made slightly more efficient for a while. Then, I moved apartments. In my new apartment, I chose to situate the Beelink Mini which I use for my homelab’s media kiosk on a shelf in a closet. This would be the perfect place to put my receipt scanner so that I could just walk in the door, scan a new receipt, and toss in the trash. The only issue was that my media kiosk is meant to be a kiosk– no keyboard for me to issue terminal commands into for triggering the scan.

So, back to the button.

I reviewed the literature (with the help Claude, admittedly), and found that there was a project that I had either overlooked in the past, or had been created since my initial attempt, called es2button. And this did exactly what I was looking for: it listened for a button event from a connected Epson scanner, and was able to trigger other events in response. And, seemingly, it worked without blocking the device from also being used as a scanner.

The Technical Details

The technical solution ended up being a little more involved than a shell alias, but not dramatically so. On the media kiosk, I run two systemd services: an es2button listener service and a receipt worker service.

The listener service runs the es2button package and waits for input from the scanner. When it sees Button 1, it writes a small protected pending file and starts the worker service. It doesn’t try to scan the receipt itself. It just records the event and gets out of the way.

The worker service runs as a dedicated epson-receipt user. It takes a lock so that two button presses can’t start two scans at once, stops the listener, waits a few seconds for the USB connection to settle, and then runs the existing receipt scanning and Paperless upload script. When the worker finishes, it starts the listener service again. If the upload fails, the scanned document is left in a restricted recovery directory instead of being discarded.

Setting it up with NixOS

I packaged this setup as a NixOS module in my nixpkgs-apocrypha repository. The module is exported as nixosModules.receiptScanner, and turns the moving parts above into two services, udev rules, the scanner group, the SANE backend, and a small manual trigger command.

Add the repository as a flake input:

inputs.nixpkgs-apocrypha.url = "github:john-craig/nixpkgs-apocrypha";

Then import the module in the host configuration. The following is the shape of my configuration; the scanner identifier, USB IDs, Paperless URL, and Epson Scan 2 package are host-specific.

{ inputs, pkgs, ... }:

{
  imports = [ inputs.nixpkgs-apocrypha.nixosModules.receiptScanner ];

  services.receiptScanner = {
    enable = true;

    # Use the stable identifier from `scanimage -L`, not a libusb bus/device ID.
    scannerDevice = "epsonscan2:Epson ES-50:SERIAL:esci2:usb:ES017E:364";
    usbVendorId = "04b8";
    usbProductId = "016c";

    paperlessUrl = "https://paperless.example";
    paperlessTokenFile = "/run/secrets/paperless-token";

    # es2button is packaged by nixpkgs-apocrypha.
    es2buttonPackage = inputs.nixpkgs-apocrypha.packages.${pkgs.system}.es2button;
    saneBackendPackage = pkgs.epsonscan2;
    epsonScan2Runtime = pkgs.epsonscan2;

    # Optional: add an existing human user to the scanner group.
    manualUser = "john";
  };
}

The es2buttonPackage line uses the package exported by the same flake, so there is no second input or separate installation step. The package builds the upstream es2button listener for Linux. The Paperless token is deliberately an external file rather than a value in the Nix configuration. On a host where Epson Scan 2 is installed outside Nix, saneBackendPackage and epsonScan2Runtime can be supplied another way, but the module requires allowExternallyProvisionedRuntime = true when no runtime path is available.

Before applying the configuration, I use these commands to find the values that belong in it:

$ scanimage -L
device `epsonscan2:Epson ES-50:SERIAL:esci2:usb:ES017E:364' is a Epson ES-50 scanner

$ lsusb
Bus 001 Device 004: ID 04b8:016c Seiko Epson Corp.

The exact SANE output and USB product ID can differ by device or backend, so these are examples rather than values to copy blindly. In particular, the module rejects a libusb: scanner identifier because bus and device numbers can change when the scanner is reconnected.

After rebuilding, the button listener is normally started by the udev rule when the scanner is plugged in. For a keyboard-free test, the module also installs a manual trigger:

sudo systemctl start receipt-scanner-manual-trigger.service

The worker reads the token through systemd’s credential mechanism, uploads to Paperless, and leaves failed uploads under /var/lib/receipt-scanner/recovery. The two service logs are the first place to look when something goes wrong:

journalctl -u receipt-scanner-listener.service -u receipt-scanner-worker.service