Improving my relationship with my scanner

Posted in Articles, JavaScript, Raspberry Pi, Tutorials

I’ve got an all‐in‐one printer/scanner/copier from HP that has worked decently for the roughly six years I’ve had it. Printers are a cursed product category where everyone swears by a different brand; likewise, for every brand, you probably know at least one person with a horror story to tell about that brand’s printers. My HP OfficeJet works fine and I’d much rather stick with it until it dies than roll the dice with something new.

I also run Paperless (paperless‐ng these days) on a Raspberry Pi attached to the side of my printer. It’s my document database. If I’m honest, it’s better described as “the place where I forget to put my documents.”

So there are two things I want to improve from my scanner:

  1. I want it to be easier to scan something straight to the folder that Paperless monitors for new documents. My printer’s touchscreen interface offers me the options “Scan to Dropbox” (hasn’t worked since Dropbox retired their v1 API) and “Scan to Network” (hasn’t worked since 2017 for reasons I never determined). My fallback option is “Scan to USB,” meaning a USB thumb drive, but even that is becoming a pain because of USB-C and dongles. I delude myself into thinking that if this process were more straightforward, I’d remember to use Paperless more often.

  2. I want it to be easier to scan a random page (like an old ad from a magazine) and have that image ready to edit/save/attach to a post as quickly as possible on whatever computer I happen to be working from at that moment. If the Dropbox integration still worked, this would be pretty simple, but since it doesn’t, my printer has no way of knowing which computer wants the resulting image.

It took me three tries to get to the right solution here, probably because it was always a problem I had to solve in order to do something else. If I’d been less distracted, I’d like to think I would’ve figured it out sooner.

First: scripts

The easiest part of all this was using SANE to control my scanner from over the network. The first search result for installing SANE on a Raspberry Pi served me quite well. A bit of trial and error allowed me to figure out the correct parameters for my particular scanner.

For instance, here’s how I can scan a color image from my flatbed and get a JPEG:

scanimage --device "airscan:e0:HP OfficeJet Pro 8740 [005A96] (USB)" \
  --mode=Color \
  --format=jpeg \
  --resolution=150 \
  -y 279.40 > test.jpg

The --device value is one of the four options emitted by scanimage -L — specifically it’s the one that worked after trial and error. The -y parameter specifies how tall the resulting image should be, and assumes that we’re scanning a letter‐sized sheet of paper. (11 inches equals 279.4 millimeters.)

Since I omitted the --source parameter, scanimage defaults to scanning from the flatbed. I could specify --source ADF to specify the document feeder instead, but then I’d need to add a --batch parameter to specify the format of the output filenames, since that command will produce as many files as there are pages in the feeder.

Anyway, a couple quick scripts for the common use cases meant that a scan was only a quick SSH session away. Since Paperless was running on the same Pi, the script for scanning to Paperless also moved the resulting files into the Paperless consumption folder, and then I was done.

Scanning an image for other purposes wasn’t as straightforward. It was easy enough to grab a color JPEG with scanimage, but then I’d need to pull it from the computer I was on via scp. To cut down on the boilerplate, I wrote a get-latest-scan script that would just scp the newest file in the output directory. That script had to exist on both my home laptop and my work laptop.

Second: hardware

I was heartened by the simplicity of my add‐to‐Paperless workflow — run one script and you’re done. The most complicated part was having to SSH into the Pi. I decided that I wanted a one‐button solution instead.

I have a number of these small buttons lying around. I’d just need to put one in some sort of 3D‐printed enclosure and then connect it to some GPIO pins on the Pi. I could then write a simple daemon that would listen for button presses and run the scan-to-paperless script.

The hardware button that starts a scan.
The button that kicks off a scan and adds the resulting PDF to Paperless.

The software was pretty simple, but I added a requirement: the daemon should turn off the LED in the button while the scan was happening, then turn it back on at the end to indicate that the scan had finished.

The script ended up being a simplified version of my volume knob daemon.

#!/usr/bin/env python
from time import sleep
import os
import signal
import subprocess
import sys
import threading
import RPi.GPIO as GPIO
import queue

GPIO_BUTTON = 10
GPIO_LED = 8

# Use a queue (of max size 1) to debounce. The first trigger will fill the
# queue and set off the scanning process; subsequent triggers will be ignored
# because the queue is full.
#
# Behavior-wise, this means that the button won't do anything while the scan
# script is running, which we emphasize by dimming the LED around the button.
QUEUE = queue.Queue(1)

# The event is used to signal our loop to trigger.
event = threading.Event()

def set_led(state):
    if state:
        GPIO.output(GPIO_LED, GPIO.HIGH)
    else:
        GPIO.output(GPIO_LED, GPIO.LOW)

def on_exit(_signo, _stack_frame):
    GPIO.cleanup()
    sys.exit(0)

signal.signal(signal.SIGINT, on_exit)

def button_callback(channel):
    try:
        QUEUE.put(1, block=False)
        event.set()
    except queue.Full:
        # Ignore.
        pass

def trigger_scan():
    p = subprocess.run(['/home/pi/bin/scan-to-paperless'])

def consume_queue():
    while not QUEUE.empty():
        set_led(False)
        trigger_scan()
        set_led(True)
        # Don't get the queue item until we're done; otherwise the queue will
        # be empty and something can worm its way in.
        QUEUE.get()
        QUEUE.task_done()

GPIO.setmode(GPIO.BOARD)
GPIO.setup(GPIO_BUTTON, GPIO.IN, pull_up_down=GPIO.PUD_DOWN)
GPIO.setup(GPIO_LED, GPIO.OUT, initial=GPIO.LOW)

GPIO.add_event_detect(GPIO_BUTTON, GPIO.RISING, callback=button_callback)

set_led(True)

while True:
    event.wait(1200)
    consume_queue()
    event.clear()

Physical buttons are liable to trigger more than once any time they’re pressed, which is why libraries exist to debounce button presses. I like the queue that holds only one item; for this script, it’s the easiest possible way to debounce, and it’s also the easiest way to ignore the button if the user tries to press it again while a scan is in progress.

The scan-to-paperless script assumes that I’ll want to scan from the automatic document feeder. This is fine, and it works identically well for adding one page versus multiple pages, as long as the pages can fit in the ADF.

The button works great. It is still hooked up to my printer and I still use it. But it only solved one of the use cases I needed, and I found myself wishing I had added a second button to the enclosure for scanning to Paperless from the flatbed. And then a third, for scanning an image and then doing, uh, something with it.

Third: the web

I am no stranger to making weird single‐purpose web sites that run on Raspberry Pis and are only accessible within my home network. The solution was staring me in the face the whole time. Web pages have buttons that can do arbitrary tasks. And web pages can display images. And browsers let you save images that appear on web pages.

Web pages also run anywhere, like on phones and tablets. And a good web page would be even easier to use than the printer’s own touchscreen, and would have a high likelihood of being adopted by other members of one’s household.

The backend

The backend has two tasks, and the first is easy:

  1. Serve the static files that live in the scan output directory — JPEGs and PDFs.
  2. Expose some sort of protocol for initiating scans and reporting their outcomes.

I did the backend in Node. Task 1 was solved with serve-static; task 2 was solved with WebSockets and the ws library.

import { createServer } from 'http';
import { readFileSync } from 'fs';
import { WebSocketServer } from 'ws';
import serveStatic from 'serve-static';
import finalhandler from 'finalhandler';

import { exec } from 'child-process-promise';

// COMMANDS
// Map codes like `paperless-scan-from-bed` to specific terminal commands.

const COMMANDS = {
  'image-scan-from-bed': ['/home/pi/bin/image-scan-from-bed.sh'],
  'image-scan-from-adf': ['/home/pi/bin/image-scan-from-adf.sh'],
  'paperless-scan-from-bed': ['/home/pi/bin/paperless-scan-from-bed.sh'],
  'paperless-scan-from-adf': ['/home/pi/bin/paperless-scan-from-adf.sh']
};

let suspending = false;

function suspend (ws) {
  ws.send('WAIT');
  suspending = true;
}

function resume (ws, result) {
  ws.send(`RESULT:${(result.stdout || "").toString()}`);
  ws.send('OK');
  suspending = false;
}

function handleError (ws, message) {
  ws.send(`ERROR:${message}`);
}

const staticHandler = serveStatic('/home/pi/scans');
const server = createServer((req, res) => {
  staticHandler(req, res, finalhandler(req, res));
});

const wss = new WebSocketServer({ server });

wss.on('connection', (ws) => {
  ws.on('message', (data) => {
    let message = data.toString('utf-8');
    console.debug('Message received: ', message);

    if (message.startsWith('SCAN:')) {

      // Shouldn't happen, but handle it just in case.
      if (suspending) {
        ws.send('ERROR:busy');
        return;
      }

      message = message.replace(/^SCAN:(\s*)(?=\w)/, '');
      if (!(message in COMMANDS)) {
        handleError(ws, 'No such command!');
        return;
      }
      let args = [...COMMANDS[message]];
      let command = args.shift();
      console.debug('Running command:', command, args);
      suspend(ws);
      exec(command, args).then((result) => {
        resume(ws, result);
      });
    }
  });

  ws.send('OK');
});

server.listen(8081);
console.log('Listening on port 8081');

I decided on a dead‐simple “protocol” for communicating between client and server:

  • WAIT/OK are sent by the server to indicate whether the scanner is busy.
  • SCAN:foo is sent by the client and indicates that the server should run whatever command is aliased to foo.
  • RESULT:bar is sent by the server to report the output of the latest scan attempt: namely, the path to the new PDF or JPEG.
  • ERROR:baz is sent by the server to report that an error of type baz happened.

I’d never worked with Node’s http.createServer directly — I’d always used a middleware like Express or Koa — but I was pleasantly surprised at how easy it was to operate both a static file server and a WebSocket listener on the same port.

The frontend

I tend to treat little projects like these as opportunities to audition frameworks that I haven’t yet worked with. I chose Preact for the frontend because create‐react‐app was starting to feel like overkill for the tiny intranet sites I was making for myself. If I had to do it over again, I probably would’ve chosen Lit just to stretch myself a bit further.

I used the CLI and kept a lot of the defaults that Preact gave me, ending up with a Bootstrap‐looking kind of site from 2013. Doesn’t matter. I needed a few things:

  1. Four buttons: two for scanning to a color JPEG, and two for scanning to a black‐and‐white PDF for Paperless. (Each needs one button for ADF and one for flatbed.)
  2. An indeterminate progress indicator for when the scan is happening. (The buttons should also be disabled during the scan.)
  3. Once the scan is done, an img tag with the scanned image, or else a link to download the PDF.
  4. The output of the shell command in case something went sideways.

I changed scarcely more than home/index.js and home/style.css, but here’s the source for home/index.js just so you can get an idea.

From there, npm run build generated a bunch of files that went into a ./build directory, which itself was easily copied over to my Paperless server. If I ever need to make changes to it, I’ll do the work of writing a script to rsync everything properly.

I set up nginx in the way I described toward the end of my last article. I don’t recall why I didn’t have the scanner backend serving up these files, or why I didn’t just configure nginx to serve up the JPEGs and PDFs from the scan output directory, but Past Andrew isn’t always thoughtful about documenting rationales.

The results

I’m more than satisfied with how this turned out. Here’s what it looks like to scan a PDF to Paperless:



The button that kicks off a scan and adds the resulting PDF to Paperless.

And here’s what it looks like to scan an image:


The button that kicks off a scan and offers the resulting JPEG for download.

Why did I write this?

Experience tells me that there are maybe a dozen of you weirdos out there that these projects really speak to, and I’ll be glad if this inspires someone to do something similar.

But it wouldn’t be worth doing if it didn’t have personal benefits, also. I did this about three months ago and promptly forgot everything about it. I’d forgotten that I’d chosen Preact. I’d forgotten whether I used Node or Ruby for the scanner backend. I unearthed, in the process of writing this, at least six bugs that needed to be fixed, including the fact that I don’t yet handle the hypothetical case of scanning multiple color pages from the ADF.

I’ve informally resolved to get better at project hygiene, even for things that nobody else on Earth will ever use. Simon Willison has spoken on this topic, and a particular idea resonates with me: the tactics that software developers use in the workspace to share knowledge carry over very well to a hobbyist coder who juggles projects.

In the latter case, the process of researching this article was an ad‐hoc knowledge transfer from myself (three months ago) to myself (now). September Andrew should really have done better at writing down his thoughts and decisions; it would’ve made this article easier for December Andrew to write.

Comments

Leave a comment

(Huh?)
What's allowed? Markdown syntax, with these caveats…
GitHub-Flavored Markdown

A couple aspects of GFM are supported:

  • Three backticks (```) above and below a section of code mark a code block. Begin a JavaScript block with ```js, Ruby with ```ruby, Python with ```python, or HTML with ```html.
  • Underscores in the middle of words (cool_method_name) will not be interpreted as emphasis.
HTML tags/attributes allowed

Whether you're writing HTML or Markdown, the resulting markup will be sanitized to remove anything not on this list.

<a href="" title="" class=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code class=""> <del datetime=""> <em> <i> <li> <ol> <pre> <q cite=""> <s> <strike> <strong> <ul>

Now what? Subscribe to this entry's comment feed to follow the discussion.