# Plustek OpticPro A320E support for the SANE genesys backend This is a patch adding support for the **Plustek OpticPro A320E**, an A3 flatbed CCD scanner for which the manufacturer ships no Linux driver, to `backend/genesys/` in sane-backends. It is offered as a **one-time contribution**. I am not in a position to take on maintenance of the code or of the device, and I would rather say that up front than imply otherwise. Everything here is written so that it can be read and judged without me present to defend it. Posted following Ralph Little's reply on sane-devel (2026-08-22, message 040366): *"So please feel free to publish it and I will take a look."* --- ## What changed since the published version (2026-08-28) This is the third version of the package. The full, version-by-version account is in **[changes.md](changes.md)**; this is the summary. | | published 2026-08-28 | now | |---|---|---| | **x scale** | 0.75 % long | **0.9991 ± 0.0001**, measured at 200, 400 and 800 dpi | | **Scan area** | conservative margins | **the whole bed**, 304.8 × 431.8 mm | | **Right-hand edge** | black tail, comb of stripes | gone — the shading window spans the full width | | **Lamp after a scan** | stayed on | **goes out when the carriage starts back** (+0.22 s) | | **Motor acceleration** | a constant-acceleration model | **the vendor's measured table**, entry for entry | Two of these change what you must do: the white calibration target now has to be **at least 305 mm wide** (see "The device" below — this scanner has no internal white strip, and A3 paper is 8 mm too narrow), and native resolutions now carry a 1.0075:1 resample from the x correction. The patch is 17 files, +3394 / −28, against sane-backends master `fcaa30a7`. The motor work also produced three findings about the **gl124 branch itself** rather than about this scanner — `FASTFED` is never set, `HOME_TABLE` is never uploaded, and `FASTNO`/`FSHDEC` are tied to the wrong table. They are reported to sane-devel separately and summarised at the end of `changes.md`. --- ## The device | | | |---|---| | USB ID | `07b3:1826` | | `bcdDevice` | `0x702` — `sane-find-scanner` reports `chip=GL128` | | Sensor | CCD (not CIS) | | Analog front end | Analog Devices AD9822 | | Bed | 304.8 x 431.8 mm (all of it scannable; measured 2026-08-29) | | Resolutions | 100, 200, 300, 400, 600, 800 dpi | | Depth | 8 bpp, colour and grayscale | | Backend name | `plustek-opticpro-a320e` | ### Why a GL128 device is driven by `gl124.cpp` SANE has no GL128 command set — `AsicType` ends at GL124. The register map read off the USB captures is GL124's, so the model is declared as `AsicType::GL124` and driven by the existing `gl124.cpp`, with A320E-specific branches where the device genuinely differs. The differences that mattered are not cosmetic. All four existing GL124 models in SANE are Canon LiDE CIS scanners; `gl124.cpp` did not contain the word "ccd" anywhere before this patch. The main divergences: - **The analog front end.** LiDE models use the "ti" front end (`SIFSEL = 3`). The A320E uses `SIFSEL = 1` and an AD9822, which needed a new write path (`gl124_set_a320e_fe`) and a new `analog_devices` frontend entry. - **The shading RAM is paged**, 252 words of payload followed by a 4-word gap. Without honouring that, the image breaks into 42-pixel stripes. - **The shading coefficients are interleaved per pixel**, not planar, and the window is uploaded in a single AHB write. - **`REG_0x31` bit 0x80 selects the speed block** (48 MHz below 600 dpi, 60 MHz at and above it). Getting this wrong costs every other pixel at 800 dpi. - **`REG_0x32` bit 0x02 is the home switch input**, not an output. Upstream clears it, which pins `HOMESNR` high and stops the carriage ever being returned home. - **The motor is not stopped when the carriage reaches home** on GL124; upstream does that only for GL846/847. On this device the remaining steps of the open-loop return grind audibly against the end stop. Each of these is documented at its site in the code, with the run number and the measurement that established it. ## Origin and authorship **The C++ in this patch was written by an AI assistant (Claude) under my direction. The empirical work behind it — USB captures of the Windows and macOS drivers, register diffing, and some 250 numbered test runs on the device — is mine.** I am stating this plainly because Ralph raised it and because a reviewer should know what they are reading. What this means in practice, and what I would ask a reviewer to weigh: - No claim in the comments is there because it sounded plausible. Each one names the run or capture that produced it. Several comments record hypotheses that turned out to be **wrong** and say so; those are kept deliberately, because the next person to look at this hardware will otherwise re-run the same dead ends. - The code has not been exercised by anyone but me, on one unit. - Static analysis results are in `code-quality.md`. Summary: cppcheck reports zero findings on the lines this patch adds, upstream's own `tools/style-check.sh` passes on all 15 touched files, and clang-tidy's 157 warnings on added lines are style rules that conflict with the genesys backend's own conventions. Each category is listed there with the reasoning. ## Licence > This patch modifies files in `backend/genesys/`, which are GPL-2.0-or-later. > The contribution is therefore offered under the same terms. To whatever > extent copyright subsists in it, I license it under GPL-2.0-or-later and > consent to relicensing under SANE's terms as applied to the genesys backend; > to whatever extent it does not, I assert no rights over it. The empirical > work (measurements, captures, analysis) is mine; the C++ was written by an > AI assistant under my direction. `backend/genesys/*` was checked on 2026-08-23 and is GPL-2+ **without** a linking exception. ## Building Dependencies beyond a normal sane-backends build: `autoconf-archive` (`autogen.sh` fails without it — `AX_CXX_COMPILE_STDCXX`), `libusb-1.0`. ```sh git clone https://gitlab.com/sane-project/backends.git cd backends git apply /path/to/a320e-genesys.patch ./autogen.sh ./configure --prefix=$PWD/../prefix --enable-libusb_1_0 --disable-avahi \ --disable-pnm-backend BACKENDS="genesys test" make -j8 && make install ``` The patch touches `backend/` only. It applies to upstream master as of `fcaa30a7`. Installing into a prefix rather than over the distribution's sane-backends is recommended while testing: ```sh SANE_CONFIG_DIR=$PWD/../prefix/etc/sane.d \ LD_LIBRARY_PATH=$PWD/../prefix/lib \ ../prefix/bin/scanimage -L ``` You will need write access to the USB device. A udev rule granting the `scanner` group access to `07b3:1826` is enough. ## Reproducing the results These are the checks I would run first on another unit. `$DEV` is the device string from `scanimage -L`. ```sh # 1. Colour, 200 dpi, small area. The baseline: full-length data, no stripes. scanimage -d "$DEV" --resolution 200 --mode Color -x 20 -y 20 \ --format=pnm > out.pnm # 2. Grayscale. Exercises the second buffer layout and the green-channel # coefficient pick; this path was broken in several different ways. scanimage -d "$DEV" --resolution 200 --mode Gray -x 20 -y 20 \ --format=pnm > gray.pnm # 3. Full width. The whole 304.8 mm bed, verified at 200, 400 and 800 dpi. # Check the right-hand edge in particular: if the last few millimetres are # black or come out as a comb of vertical stripes, the calibration target # was too narrow (see below) or the calibration cache is stale. scanimage -d "$DEV" --resolution 200 --mode Color -x 304.8 -y 20 \ --format=pnm > wide.pnm # 4. Geometry. Scan graph paper and measure a known square in both axes; # at 200 dpi a 7 mm square is 55.1 px in both directions. The x scale was # 0.75 % long until 2026-08-29 and is now measured at 0.999 +- 0.001; the # residual channel misregistration is 0 lines. ``` **A white calibration target IS needed, and it must be at least 305 mm wide.** This device has no internal white strip: its lid backing is nearly black (64.8 DN against a white sheet's 252.6 in the vendor's own captures 05b/05c), so the white reference is whatever lies on the glass at the front edge when the driver calibrates. Since 2026-08-29 the shading window spans the full width of the bed, so the target has to span it too — a neutral-white sheet of at least 305 mm, which no standard paper size provides (A3 is 297 mm, 8 mm short). The measurements in this document were made with a 310 mm neutral-white card. If the target is too narrow, the uncovered strip at the right-hand edge is calibrated against darkness and scans as bright noise. If it is missing altogether, the whole image is wrong, not merely dim. If scans are shifted, the two new options correct the origin: ```sh scanimage -d "$DEV" --x-origin-offset -1.2 --y-origin-offset 0.8 ... ``` Both are in mm, range +-5, default 0, and marked advanced. They exist because `model->x_offset` and `model->y_offset` are measured values and I have only one unit to measure (see limitations). The vendor driver has the same control under the name `AdjustOrigin`. ## Known limitations Stated without varnish. None of these are hidden in the code; each is commented at its site. - **The x scale is corrected on average, but it varies with position by +-0.4 %.** The sensor's real pitch is about 403 dpi while the ASIC's pixel grid is 400, which made every image 0.75 % too wide until 2026-08-29. The driver now compensates in the millimetre-to-pixel conversions (`a320e_x_scale`, `low.cpp`) rather than in `optical_resolution`, which must stay 400 — it is the grid the decimation, the shading data and the calibration widths are all counted on. Measured with a ruler across the bed at 200, 400 and 800 dpi: **0.9991 +- 0.0001**, against 1.0067 uncorrected. What remains is a position dependence of +-0.4 % across the width, largest at the origin end. Removing that would need a position-dependent resample in the image pipeline; it is a different order of work and it is not here. The y scale is 0.9997 and needs no correction. - **The native resolutions are no longer pixel-for-pixel from the sensor.** The x correction is applied by asking the hardware for 0.75 % more pixels and letting `ImagePipelineNodeScaleRows` scale the row back, so 200, 400 and 800 dpi now carry a 1.0075:1 resample that they did not have before. The cost was measured on the same target with the correction on and off: edge width 146.3 um against 146.5 um, i.e. nothing above the repeatability of the measurement. It is stated because it is a real change in the data path. - **Colour management is unfinished.** There is no ICC handling. The brightness question underneath it has been answered, though, and the answer is in the code: `target_code = 0x8000` maps the calibration white to half scale, and that margin is not optional — the ASIC's shading division **wraps** rather than clips, so an original brighter than the calibration reference would come out black (runs 122-132). Plain paper is dimmer than the calibration reference, and before the compensation it came out at 174 of 255. The compensation is therefore applied in the **gamma table**, which the ASIC evaluates after the shading division and where overflow clips instead of wrapping. That table is the backend's own gamma curve for this sensor (gamma 2.2) with every entry multiplied by 1.40 and clipped, so the whole transfer is `out = min(1, 1.40 * raw^(1/2.2))`. It first shipped with a knee at 90 % of full scale, chosen so that the curve could not clip at all. That was the wrong trade and the knee is gone: measured on a printed original, it gave the top third of the range a slope five times shallower than the rest, and removing it cost 19 clipped pixels out of 3.6 million while the band above 229 DN regained four times as many distinct output levels. What this does not give you is colorimetric accuracy — it gives a sensible white point. **At 16 bpp the gamma table is left out and the data stays linear.** The vendor is brighter still (251) but saturates 99.4 % of its blue channel on ordinary white paper; that part is deliberately not copied. - **A period-2 ripple remains at the ~1 DN level.** Its cause was the sign convention of the parity correction, and normalising that brought every resolution to 0.4-1.1 DN (A/B control: 24.5 DN). One residual is open: at 800 dpi the green channel moved from 1.09 to 1.26 DN when the black level was fixed, and I have not chased it. It is at the noise floor. - **The black level is 8x / 15x / 31x the vendor's** (R/G/B). This is now understood rather than merely observed, which is the honest way to state it. The driver already aims at the vendor's own target (`2 x PixelDark`, per parity group). The residue is that the two parity groups of the CCD differ in **sensitivity** — 4.8 / 4.3 / 1.8 % on R/G/B, a multiplicative difference that scales with the signal and is independent of the PGA — and the black level has to be high enough to hold that difference without the dark shading clipping. The vendor's device has the same asymmetry but smaller (240-650 DN16 against our 1300-1900). The AFE's offset and gain registers have been measured and ruled out as the place to fix it; what is left is the sensor readout and CDS sampling, which I have not reached. It costs dynamic range. In the image it does **not** show up as a raised black floor: on the same original in the same position, the vendor's image is an affine map of ours over the whole scale (constant term +0.6 DN on green and blue, -7.4 DN on red, residual 3.2 DN at worst across the 2-99 % quantiles). The red difference is their black point, not our floor — they push 0.224 % of the red channel to exactly zero and this driver pushes none. - **100 dpi is produced by ASIC line skipping** (`LINESEL`), not by the vendor's route of scanning at 200 dpi and dropping lines in software. The measured result is correct (0.998 normalised) but the mechanism differs. - **150 dpi is not offered.** It was the only mode non-native on both axes and could not be made to work; the ASIC produced no lines at all. SANE's `sanei_constrain_value` gives an application asking for 150 the nearest listed value with `SANE_STATUS_INEXACT`. - **`model->x_offset` and `y_offset` were measured on one unit.** Hence the origin offset options above. - **`stagger_y` is not implemented.** The vendor defines `StaggeredLine = -4`; SANE implements staggering through its own machinery and the two have not been reconciled. - **`y_size_calib_mm` and `line_mode_color_order` are unverified.** Both are marked `UNFINISHED` in the model table. - **Lineart mode is untested.** Colour and grayscale have been exercised; lineart goes through SANE's generic path and I have not checked it. - **No man page entry, no `testsuite/` cases.** Both are acknowledged gaps. There is, however, a geometry check that needs no device: it opens the backend in testing mode (`enable_testing_mode`, `test_scanner_interface`), asks for a set of window widths at every resolution and reads back what the driver asked the hardware for against what it promises the frontend. Their ratio is the image's x scale, so the correction above is verifiable from a desk. It lives in the project repository rather than in this patch (`tools/xmittakaava.cpp`); if a testsuite case would be welcome, that is the shape it would take, and I will submit it. - **The `WARMUP` flag is deliberately absent.** The lamp is an LED and both vendor definitions specify a zero warm-up time; enabling the flag also crashes `gl124_init_regs_for_warmup` in `compute_session`. ### Development switches still present The patch still contains six `A320E_*` environment-variable switches (`A320E_AFE`, `A320E_FORCE`, `A320E_YOFF`, `A320E_UNITY_SHADING`, `A320E_HOME_TIMEOUT_MS`, `A320E_XSCALE`). They are diagnostic instruments for the open questions above, and each is marked in the code as not intended for upstream. They default to the production behaviour and are inert unless set. `A320E_PARITY_FILTER` was in the first version of this package and has since been removed: the A/B run it existed to make possible was done, the filter turned out to be necessary, and the switch had no reason to survive. ## Files in this package | file | contents | |---|---| | `README.md` | this file | | `a320e-genesys.patch` | the patch (17 files, +3394 / −28), against upstream `fcaa30a7` | | `changes.md` | What changed in each published version, and why | | `code-quality.md` | static analysis: method, results, and the reasoning for every warning left unaddressed | | `hardware-gl128.md` | GL128-level findings: ASIC identification, register semantics, the AD9822 front end, what differs from GL124. This is the part most likely to transfer to other GL124/GL128-family devices, including the OpticFilm 8200i (`07b3:1825`) that your `genesys.desc` already flags as "GL128 based" | | `hardware-a320e.md` | A320E-specific: tables, calibration behaviour, the resolution model, origin adjustment | Third-party material (datasheets, the vendor's `.icm` and `.ini` files) is **referenced, not redistributed**. Identifiers are given ahead of URLs, since mirror addresses die and identifiers do not. `hardware-gl128.md` and `hardware-a320e.md` are the measurement notes and the USB capture analysis I said I would publish along with the patch. They are that material distilled and written out in English; the raw working notes behind them are in Finnish and are not included, because they would not be useful to you in that form. ## Names you will meet in the code comments The comments cite the evidence for each claim by name. Those names are files in my working repository, not in this package, and this is what they are: | cited as | what it is | |---|---| | `HAVAINNOT.md`, `notes/usb/HAVAINNOT.md` | the USB capture analysis — what each frame of the vendor's traffic was found to mean. Finnish. Distilled into `hardware-gl128.md`. | | `notes/linux/README.md` | the device run log: what was run, what was measured, what it refuted. Finnish. The run numbers in the comments index into it. | | `ROADMAP` | the project's working document, including the hypotheses that were killed. Finnish. | | `tools/afedump.py`, `ahbdump.py`, `regseq.py`, `regat.py`, `regdump.py`, `sensorregs.py`, `slopedump.py`, `winscan.py` | capture-analysis scripts — they decode the vendor's USB traffic into register writes, AFE writes and AHB uploads. | | `docs/AD9822.pdf` | the Analog Devices datasheet. Third party; referenced, not redistributed. | | `docs/ScanApi_07b3_1826.ini` | the vendor's own device definition, extracted from the Windows driver package `2D1_2D3-V6031_14L.exe` v6.0.3.1. Third party; referenced, not redistributed. | A reference like "run 137" or "capture 05b" is therefore checkable in principle but not from this package alone. If any specific claim matters to you, ask and I will produce the measurement behind it — that offer stands for as long as I have the device.