An Open Scan-to-PC Daemon for Brother Scanners

Philipp Mundhenk · November 1, 2026

The Brother Scanner Docker container has one part I never wrote and could never read: brscan-skey, the closed-source daemon from Brother that makes the scanner’s front panel show my server as a Scan to PC destination and forwards the button presses. Shortly after the 2.0 release that daemon turned out to have a bug that silently kills the buttons after a few days, the vendor was not going to fix it, and the license allowed me to. Worse even, they told me to get in touch with the community for support. So I replaced it. openskey is an open reimplementation of that daemon in Rust, and this is the story of how it came to be and what I learned from the device on the way.

Background

When you press Scan on a Brother multifunction device, the panel offers the computers it knows as destinations. It knows them because a small daemon on each computer has told the device “I am here, send me the button presses”, and keeps telling it, because the device forgets a destination after six minutes. When you pick a destination and a function, the device sends a short network message to that computer, the daemon there runs a script, and the script does the actual scan through the regular scanner driver. In the container that script is the pipeline from the previous post: Front pages, rear pages, blank page removal, PDF, OCR, etc.

Brother ships this daemon as a binary package for Linux, without source, and it has done its job somewhat for years. Occasionally, the connection dropped out, but this didn’t happen too often and was somewhat bearable.

The problem

In October, after adding the --refresh function, trying to improve the stability, users of the container reported that the scanner buttons stopped working after a few days, while scanning from the web interface kept working. A restart of the container fixed it, for a few days. The log eventually gave it away: Too many open files. The daemon leaks one network socket every time it registers with the device, and since the container had just learned to refresh that registration every two minutes (which fixed the destination vanishing from the panel), the leak reached the open-file limit in days instead of years. Once there, the daemon neither receives button presses nor re-registers, so the destination disappears from the panel and nothing in the log says why.

I did what one does and asked Brother. Brother referred to not supporting its Linux drivers. Fair enough, the license agreement says the same, but it also explicitly permits modifying the drivers and preparing derivative works. The container got a workaround first, a raised open-file limit and a watchdog that restarts the daemon before it hits the wall. That keeps the buttons working, but it is a bandage on a binary I cannot see into, and the protocol underneath is not complicated. So I took the license up on its offer.

What the device actually speaks

The daemon’s job splits into two halves, and both are plain, old network protocols.

The registration is an SNMP write to the device, four short text strings, one per function (image, OCR, e-mail, file), each saying who we are, which address to send the event to, and how long the registration should last. That duration is fixed at six minutes, the device never renews it on its own, so a destination that is not refreshed quietly drops off the panel. The button press is a single UDP datagram to port 54925 on the registered computer: A tiny header, then the same kind of text, naming the function, four option slots, a registration id and a sequence number. The device sends every press twice, two seconds apart, with the same sequence number, which is how a second scan used to start on its own now and then.

All of this was worked out from packet captures of the stock daemon talking to my MFC-L2700DW, verified against a capture a user had attached to an issue three years earlier, and written down in the project’s PROTOCOL.md. The implementation contains no Brother code, it is a clean reimplementation of what goes over the wire.

The rebuild

openskey is a little over two thousand lines of Rust, tests not counted, with no dependencies at all, SNMP encoder included. It registers, re-registers every two minutes from a socket it closes again (nothing to leak, by construction), listens for the button datagrams, drops the device’s resend by remembering the sequence numbers it has seen, and runs the configured script with every field of the event in its environment. The scripts are the same ones the stock daemon called, with the same two arguments, so the container did not have to change for the swap.

Two things it does that the original does not. After every registration it reads the registration back from the device and only reports success when the device confirms it, so a silent failure is no longer possible; openskey selftest does the same check from the command line, with an exit code for scripts. And openskey status returns the scanner’s own view of things as JSON: Whether it is reachable, whether it lists us, model, serial, firmware, the text on its panel, and whether the network scan key is supported and enabled at all. The container uses that for its reachability check and shows it in the web interface and the Home Assistant card.

{"device_ip":"192.168.1.50","reachable":true,"registered":true,
 "model":"MFC-L2700DW","serial":"E73941F6N621420","firmware":"Y2309112149",
 "display":"Scan:   drück",
 "net_scanner_supported":true,"net_scanner_enabled":true,
 "scan_key_supported":true,"scan_key_enabled":true}

It is a scanner tool, so printer objects from multi-function devices, such as toner, drum and page counters, are deliberately not exposed. Other projects do that better.

Testing without a scanner

The part that was missing from the container for automated tests was a mock device. mock-device is a small SNMP agent that answers like my MFC-L2700DW, accepts registrations, answers the self-test truthfully only after it received one, and can inject button presses with chosen options. Every module is tested against it in-process, an end-to-end test drives the real binaries through the whole flow, and the container’s own Playwright suite starts openskey against the mock inside the image, presses a button, and checks that exactly one job appears with the event’s fields on it. Coverage is gated at 95 percent of lines and currently sits at 98, Clippy runs with its pedantic and nursery lint sets as errors, every public item has to be documented, and a robustness test feeds thousands of random and mutated datagrams through every parser to make sure nothing panics. None of that needs hardware, which matters, because I only have one model.

The same mock can decode what the real brscan-skey sends, for a side-by-side comparison.

The device speaks Roman-8

The Home Assistant card showed the panel text as Scan: dr�ck, with a replacement character where the ü should be. The bytes on the wire were 64 72 CF 63 6B, and 0xCF is not an ü in any encoding I had in mind; in Latin-1 it is an Ï. It is an ü in HP Roman-8, the printer character set of the PCL era, and the device even says so if asked: The Printer MIB has an object for the character set of its text objects, and Brother’s answer is the IANA number for Roman-8. openskey now asks that object first and decodes the panel text with the declared set. Scan: drück it is.

Using it

In the container, openskey is built into the image and one variable switches to it:

services:
  scanner:
    image: ghcr.io/philippmundhenk/brotherscannerdocker:v2
    environment:
      - IPADDRESS=192.168.1.50
      - MODEL=MFC-L2700DW
      - NAME=Scanner
      - SKEY_DAEMON=openskey

Brother’s daemon stays the default for now, until openskey has seen more than one model. Outside the container it is a single static binary that takes the scanner’s address, this host’s address and a name, and runs the scripts you give it. From the next release on, the releases carry it for x86_64, aarch64 and armv7, so a Raspberry Pi next to the scanner works as well as a NAS. On Debian, Ubuntu and Raspberry Pi OS there is a package that installs it as a systemd service with its own user, ready to run once the configuration file has the scanner’s address:

sudo apt install ./openskey_<version>_arm64.deb
sudoedit /etc/openskey/openskey.env
sudo systemctl enable --now openskey

The README has the details and the variables. If you own a different Brother model, a capture of its button events, which openskey prints with SKEY_DEBUG=1, is the most useful thing you can send. There is a model report form for exactly that.

How it was built

Most of openskey was implemented together with Claude Code. While I did the protocol intelligence, debugging, etc., Claude did the implementation.

openskey is an independent project, not affiliated with, authorised by or endorsed by Brother Industries. It reimplements a network protocol for interoperability and contains no Brother code.

Improvements

  • More models: Only the MFC-L2700DW has been seen on the wire, other devices may fill the four option slots or format the event differently, and the call for support says how to help.
  • Newer firmware: Recent devices register over HTTP instead of SNMP, which openskey does not implement yet, so those still need the stock daemon.
  • Default in the container: Once a few more models have confirmed the protocol, openskey should become the default and the watchdog a relic.

As always, the code is on GitHub, and improvements are welcome.

References

LinkedIn, Facebook