背景

本文开坑于 2019 年 8 月。🐦了两年半。

2018 年年底,因为特别心水能自动把纸吃进去的结构,在 ebay 上以低廉的价钱拍了一台二手 Brother DS-620D。收到货一看,卖的这么便宜果然有问题,货不对板啊,实际上给我寄过来的居然是一台更高级的 DS-720D。怎么办呢?当然是开心地收下了。

这台便携扫描仪长这样:

Brother DS-720D

它能够将送到嘴边的纸张自动吃进去拉出来并顺便生成双面彩色图像。由于对纸张长度几乎没有限制,十分适合扫描购物小票等长条形文档。

该型号现已停产,但同系列仍有新款在售产品。本文的精神可能也适用于这些产品。

驱动和配套软件安装

在 Brother 的网站上很容易找到相关软件:https://www.brother.com/support/ds-720d/downloads。安装完后会得到内核扩展一枚和叫作 DSmobileCapture 的小软件。我们掏出一张尚未使用的厕纸,揉一揉,塞进去,实施彩色扫描,得到如下画面:

DSmobileCapture screenshot

问题

看起来这个东西就好用了,但还是有些问题。

这个 DSmobileCapture 似乎是 macOS 上唯一能够调用该扫描仪的软件。系统自带的 Image Capture 和常见扫描软件完全不显示该扫描仪。这使得它很难被集成到我们的工作流当中。

这个软件还有一些不符合我使用习惯的设计。其流程为连续扫描多张文件设计,如果您只需要扫描单张,则在扫描完成后需要点击取消以停止继续扫描。同时无论您需要扫描几张,必须先将第一张纸喂进去才能点击开始。

尝试:用 Raspberry Pi 将扫描仪转换到标准协议

一个简单的思路是,既然该扫描仪提供了 linux 驱动,我们可以将其接在一台 Raspberry Pi 上,提供标准的网络扫描仪协议,便可在 macOS 上编程控制。

系统都刷好了,我突然意识到所谓的 linux 驱动并没有 ARM 版本,Raspberry Pi 无法使用。也没有代码可以自行编译。

当然理论上我们还是可以安排一台 x86 机器或虚拟机干这个事情,但是我不喜欢这么做。

尝试:TWAIN 协议

观察驱动安装产生的文件,我们可以从多个地方观察到 TWAIN 这个词。比如:

  • /Library/Image Capture/TWAIN Data Sources/DS-720.ds 的路径
  • /Applications/DSmobileCapture.app/Contents/MacOS/AvCaptureTool_lng.plist 的内容
  • /Applications/DSmobileCapture.app/Contents/MacOS/DSmobileCapture 的符号们

那么 TWAIN 是什么呢?搜索得知是某通用扫描仪协议。据说 macOS 从某版本起偷偷去掉了相关支持,所以大家都找不到这个设备了。

诶那没支持了 DSmobileCapture 是怎么工作的呢?通过观察和学习,我们攒出了如下代码,其中 twain.h 可从其寒酸的官网取得。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
#include <condition_variable>
#include <fstream>
#include <mutex>
#include "twain.h"

TW_IDENTITY app_identity = {
    .Id = 0x0,
    .Version = {
        .MajorNum = 0x1,
        .MinorNum = 0x0,
        .Language = 0xd,
        .Country = 0x1,
        .Info = "1.0",
    },
    .ProtocolMajor = 0x1,
    .ProtocolMinor = 0x9,
    .SupportedGroups = 0x3,
    .Manufacturer = "",
    .ProductFamily = "",
    .ProductName = "",
};

TW_IDENTITY ds_identity = {
    .Id = 0x0,
    .ProductName = "",
};

bool ready = false;
std::mutex mutex;
std::condition_variable cv;

void save_image(char **handle) {
    HLock(handle);
    unsigned size = GetHandleSize(handle);
    std::ofstream f("foo.tiff", std::ios::binary);
    f.write(*handle, size);
    HUnlock(handle);
    DisposeHandle(handle);
}

TW_UINT16 TWAIN_callback(pTW_IDENTITY pOrigin, pTW_IDENTITY pDest, TW_UINT32 DG, TW_UINT16 DAT, TW_UINT16 MSG, TW_MEMREF pData) {
    if (MSG == MSG_XFERREADY) {
        std::lock_guard<std::mutex> guard(mutex);
        ready = true;
        cv.notify_one();
    } else if (MSG == MSG_CLOSEDSREQ) {
        printf("close req\n");
    }
    return TWRC_SUCCESS;
}

int main() {
    TW_UINT16 ret;
    // open dsm
    ret = DSM_Entry(&app_identity, nullptr, DG_CONTROL, DAT_PARENT, MSG_OPENDSM, nullptr);
    if (ret != TWRC_SUCCESS) return ret;
    // register callback
    TW_CALLBACK callback = {
        .CallBackProc = (TW_MEMREF)TWAIN_callback,
        .RefCon = 0,
        .Message = 0,
    };
    ret = DSM_Entry(&app_identity, nullptr, DG_CONTROL, DAT_CALLBACK, MSG_REGISTER_CALLBACK, (TW_MEMREF)&callback);
    if (ret != TWRC_SUCCESS) return ret;
    // open ds
    ret = DSM_Entry(&app_identity, nullptr, DG_CONTROL, DAT_IDENTITY, MSG_OPENDS, (TW_MEMREF)&ds_identity);
    if (ret != TWRC_SUCCESS) return ret;
    // enable ds
    TW_USERINTERFACE ui = {
        .ShowUI = 0,
        .ModalUI = 0,
        .hParent = 0,
    };
    ret = DSM_Entry(&app_identity, &ds_identity, DG_CONTROL, DAT_USERINTERFACE, MSG_ENABLEDS, (TW_MEMREF)&ui);
    if (ret != TWRC_SUCCESS) return ret;
    // wait for transfer
    {
        std::unique_lock<std::mutex> lk(mutex);
        cv.wait(lk, []{ return ready; });
    }
    // transfer
    TW_PENDINGXFERS pendings;
    do {
        char **picHandle = nullptr;
        ret = DSM_Entry(&app_identity, &ds_identity, DG_IMAGE, DAT_IMAGENATIVEXFER, MSG_GET, (TW_MEMREF)&picHandle);
        if (ret != TWRC_XFERDONE) return ret;
        save_image(picHandle);
        ret = DSM_Entry(&app_identity, &ds_identity, DG_CONTROL, DAT_PENDINGXFERS, MSG_ENDXFER, (TW_MEMREF)&pendings);
        if (ret != TWRC_SUCCESS) return ret;
    } while (pendings.Count != 0);
    // disable ds
    ret = DSM_Entry(&app_identity, &ds_identity, DG_CONTROL, DAT_USERINTERFACE, MSG_DISABLEDS, (TW_MEMREF)&ui);
    if (ret != TWRC_SUCCESS) return ret;
    // close ds
    ret = DSM_Entry(&app_identity, nullptr, DG_CONTROL, DAT_IDENTITY, MSG_OPENDS, (TW_MEMREF)&ds_identity);
    if (ret != TWRC_SUCCESS) return ret;
}

编译,运行:

1
2
g++ -oscan -std=c++11 -framework Carbon -framework TWAIN scan.cpp
./scan

扫出东西来了耶!

不过还是有一些小问题:

  • 这个东西不支持调各种参数或多台扫描设备(多花点时间可以解决)
  • 这个东西是用了 deprecated Carbon API,说不定哪天就没了。但是驱动那头用了有关 API 我们也没有办法。

还有一个大问题:这个东西居然还会出窗口!扫完了还要点取消!还是要先把纸放进去!程序控制流还会被这个窗口抢走!

TWAIN scan screenshot

原来之前我们在 DSmobileCapture 里看到的这个窗口不是它自己出的,而是 TWAIN Data Source 那头出的!

尝试:Wireshark 抓 USB

听说 Wireshark 可以抓 USB,于是试试看。

首先在较新的 macOS 下抓 USB 需要关闭 SIP。

抓完以后发现一大堆眯眯小的帧。看不懂。放弃。(然后把 SIP 开回来)

尝试:直接和驱动联系

既然 TWAIN Data Source 会出窗口,很难想象这是内核扩展的一部分,那么这个 Data Source 是个怎样的存在呢?我们能不能仿制一个呢?

容易发现有这样一个文件 /Library/Image Capture/TWAIN Data Sources/DS-720D.ds/DS-720Dfile 告诉我们它看起来像是 dylib。观察发现它确实提供了 DS_Entry 函数。

随意观察几个函数,可以发现很多地方都用到了 printDebugLog 打印了一些有用的信息,可是我们却什么都没有看到。猜测是 debug level 不够高。那么怎么调高呢?猜测可能来自环境变量,配置文件或是硬编码常量。

先尝试最简单的,在 /Library/Image Capture/TWAIN Data Sources/DS-720D.dsgrep -ir debug *,一下就发现了 Versions/A/avscan.plist 这个文件,进去把 DebugLevel 改成一个大数,即可看到调试信息。关键过程大概分几步

  1. InitializeDriver
  2. InitializeScanner
  3. StartScanJob
  4. GetADFStatus(检查是否有纸)
  5. SetScanParameter
  6. TuneScanLength
  7. SetGammaTable
  8. StartScan
  9. ReadScanEx
  10. StopScan
  11. EndScanJob

观察它们的实现,发现其实都是调用的 /Library/Image Capture/TWAIN Data Sources/DS-720D.ds/Resources/DS-720D.dylib 里面的接口。那么事情就简单了,我们只要猜对接口定义,依葫芦画瓢进行同样的调用即可。

其中最复杂的问题是 SetScanParameter 涉及到一个巨大的结构体,猜起来比较困难,但是好在这个 dylib 也提供了翔实的调试信息,只需 mkdir -p /tmp/DrvLog && echo "DebugLevel 1000" > /tmp/DrvLog/Debug.conf 即可在 /tmp/DrvLog 中看到很多重要文件。

在这个结构体中还有一些数值意义不明,不过经过一番搜索,GitHub 上似乎有一个用了同样结构体的项目。尽管它并没有给出结构体和枚举类型的定义,我们还是可以从枚举类型值的名称和相关注释窥见个中含义。

最后,我们猜出了相关函数、结构体和枚举类型的大致定义。其中有六个字节和部分枚举值猜不出来,但既然没有观察到它们被使用,那也无伤大雅。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
enum ScanMode {
    // SMO_LINEART,
    // SMO_HALFTONE,
    SMO_GRAY = 2,
    SMO_COLOR = 4,
};

enum ScanMethod {
    SME_ADFFRONT = 1,
    // SME_ADFREAR,
    SME_DUPLEX = 4,
};

// size = 69
#pragma pack(push, 1)
struct ScanParameter {
    uint16_t Left; // 0
    uint16_t Top; // 2
    uint16_t Width; // 4
    uint16_t Length; // 6
    uint16_t PixelNum; // 8
    uint16_t LineNum; // 10
    uint8_t ScanMode; // 12
    uint8_t ScanMethod; // 13
    uint8_t BitPerPixel; // 14
    uint8_t ScanSpeed; // 15
    uint8_t Contrast; // 16
    uint8_t Brightness; // 17
    uint8_t HTPatternNo; // 18
    uint8_t Highlight; // 19
    uint8_t Shadow; // 20
    uint8_t ColorFilter; // 21
    uint8_t Invert; // 22
    uint8_t IntelligentMultiFeedStyle; // 23
    uint16_t ExtScanParam; // 24
    uint16_t RExposure; // 26
    uint16_t GExposure; // 28
    uint16_t BExposure; // 30
    uint16_t XRes; // 32
    uint16_t YRes; // 35
    uint16_t RGain; // 36
    uint16_t GGain; // 38
    uint16_t BGain; // 40
    uint16_t lensPosition; // 42
    uint8_t byBackgroundLines; // 44
    uint8_t byPagesThisJob; // 45
    uint8_t CompressionArgument; // 46
    uint16_t HiWordLength; // 47
    uint16_t HiWordLineNum; // 49
    uint8_t UltraSonicIntension; // 51
    uint32_t ExtScanParam2; // 52
    uint8_t unknown0[6];
    uint8_t EnableBatchScan; // 62
    uint16_t wPaperLength; // 63
    uint16_t ExtIndex; // 65
    uint16_t ExtSize; // 67
};

struct IOStatus {
    void *buffer;
    uint32_t requestBytes;
    uint32_t readLines;
    uint32_t readBytes;
};
#pragma pack(pop)

int (*InitializeDriver)();
int (*TerminateDriver)();
int (*InitializeScanner)();
int (*GetADFStatus)(uint8_t *status);
int (*SetScanParameter)(ScanParameter *param);
int (*SetGammaTable)(uint8_t *table, uint8_t color, uint16_t size);
int (*TuneScanLength)(uint16_t direction, uint16_t line, uint8_t portion);
int (*ReadScanEx)(IOStatus *io_status);
int (*StopScan)();
int (*StartScan)();
int (*EndScanJob)();
int (*StartScanJob)();
int (*DoCancel)();

然后依次调用相关函数就可以开心的扫描啦。讨厌的窗口也不见啦。

SetScanParameter, TuneScanLength, StartScanJob, EndScanJob 不调用似乎也没事。TerminateDriver 不调用可能出现 Segmentation Fault。

最后我们会通过 ReadScanEx 得到一坨数据。并没有什么文件头看不出类型。用十六进制编辑器查看可以观察到其中数据有一定重复性,那么猜测是裸像素点。试着转换一下,成功:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import math
import sys
from PIL import Image

BYTES_PER_PIXEL = 3  # RGB

raw_file = sys.argv[1]
output_file = sys.argv[2]
line_width = int(sys.argv[3])

with open(raw_file, 'rb') as f:
    data = f.read()
pixels = [
    tuple(data[i:i+BYTES_PER_PIXEL])
    for i in range(0, len(data), BYTES_PER_PIXEL)
]

im = Image.new("RGB", (line_width, math.ceil(len(pixels) / line_width)))
im.putdata(pixels)
im.save(output_file)