Hama myScan Linux driver tools: scan mode, frame decoding, stitching

UVC extension-unit control (mouse/scan mode, LEDs, exposure), JPEG header
fix and embedded laser record decoding, session recorder, and a stitcher
that combines laser dead reckoning with phase correlation and ECC refinement.
FINDINGS.md holds the reverse-engineered protocol and laser calibration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erik
2026-10-02 10:41:22 +00:00
co-authored by Claude Opus 5.5
commit e1c1012c1c
13 changed files with 1751 additions and 0 deletions
+345
View File
@@ -0,0 +1,345 @@
# Hama myScan scanner mouse: reverse-engineering findings
Status as of 2026-10-02: **scanning works on Linux.** The stock `uvcvideo` and
`hid-generic` drivers handle the device. A small user-space tool switches the
mouse into scan mode (LED illumination plus per-frame sensor data) through the
UVC extension unit. Everything needed was reverse engineered from the
**Dacuda Scanner Mouse for Mac 2.4** package (see "The real driver" below).
What remains is software: auto-exposure and stitching frames into a page.
## Quick reference: how to scan
All access goes through UVC extension unit 6, selector 2 (5 bytes), using
`UVCIOC_CTRL_QUERY` on the video node. See `myscan/xu.py`.
```
bridge write : SET_CUR [0x00, reg, 0, value, 0]
bridge read : SET_CUR [0x80, reg, 0, 0, 0] then GET_CUR -> value in byte 3
```
Start scanning, exactly as `Hal::OVMouseManager::startVideo` does:
1. write `D8 = 0x00`, poll `D8` until it reads `0xFF` ("disable mouse", standby)
2. write `2D = 0x60` and `E7 = 0x28` (each one is read back to verify)
3. optionally write `E6` = exposure (`round(ms × 49.23)`, 0–255 ≈ 0–5.2 ms)
4. write `D8 = 0xE1`, poll until `0x99`; then stream MJPEG from `/dev/videoN`
Stop: write `D8 = 0xE0`, poll until `0x11` (normal mouse mode again; `0x11`
is also the idle value after plugging in).
Verified on the device: the acks come back exactly as expected (`FF`, `99`,
`11`), frames light up in proportion to `E6`, and each frame carries a live
sensor record (see "Embedded sensor record").
## Hardware probing results (device attached)
### USB identity
```
idVendor=274f idProduct=c001 bcdDevice=c6.16
Manufacturer: Omnivision Product: Scanner Mouse
```
High-speed USB 2.0, one configuration, max power 500 mA. It's a composite
device, and the kernel binds it without any extra driver:
| Interface | Class | Kernel driver | Notes |
|---|---|---|---|
| 0 | Video Control (UVC 1.00) | `uvcvideo` | Interrupt status EP 0x82; one vendor extension unit |
| 1 | Video Streaming | `uvcvideo` | Isochronous EP 0x81, 3×1020 B; MJPEG only |
| 2 | HID mouse | `hid-generic` | Interrupt EP 0x83 |
dmesg notes "UVC non compliance - GET_DEF(PROBE) not supported", which the
kernel works around automatically.
### Video format and the bogus JPEG header
- The only format is MJPEG, **640×296 at 30 fps** (`dwMaxVideoFrameBufferSize`
378,880 = 640×296×2).
- Each frame is a complete JPEG (FFD8 … FFD9, about 4.6–18 KB, YCbCr 4:2:2),
**but its SOF0 header claims 480×640**. Decoded as-is, the image looks
smeared and the data runs out after 400 rows, because 1500 MCUs of 16×8 at
480 px wide make 400 rows.
- **Fix:** rewrite the SOF0 height/width fields to 296/640 before decoding.
That gives clean 640×296 frames, with the 1500 MCUs filling 40 × 37.5 MCU
rows:
```python
i = raw.find(b'\xff\xc0')
raw = raw[:i+5] + (296).to_bytes(2,'big') + (640).to_bytes(2,'big') + raw[i+9:]
```
- The image is effectively grayscale (all channels equal).
Verified: a frame taken with the mouse lifted toward a window clearly shows
window blinds. A frame taken while sliding over printed text shows text
strokes (motion-blurred, because the exposure is long).
### Illumination (solved)
In mouse mode the LEDs are off and frames are almost black (mean ≈ 8/255).
In scan mode the LEDs are strobed for the exposure window set in bridge
register `E6`. The power-on default is `E6 = 5` (≈ 0.1 ms), which is still
nearly black. Measured brightness (mean / max) on a dark fabric mousepad:
| E6 | exposure | mean | max |
|---|---|---|---|
| 5 | 0.10 ms | 8.9 | 24 |
| 40 | 0.81 ms | 22.4 | 141 |
| 120 | 2.44 ms | 51.2 | 228 |
| 255 | 5.18 ms | 89.7 | 250 |
The Mac app runs a PID auto-exposure loop over `E6` (strings `pidGainP/I/D`,
`exposureTimeUsMin/Max`) and stores per-device calibration in the mouse's
EEPROM ("HardwareData": `exposureTimeUs`, `imagerGain`, laser scaling, …).
### UVC controls
- The Input Terminal (camera) and Processing Unit advertise **no** standard
controls, so there are no exposure, gain or brightness controls through V4L2.
- **Extension Unit ID 6, GUID `dd880f8a-1cba-4954-8a25-f7875967f0f7`, 3 controls**
(queried with `UVCIOC_CTRL_QUERY` on `/dev/video4`):
| Selector | Length | Info (GET/SET) | GET_CUR | Interpretation |
|---|---|---|---|---|
| 1 | 20 | 0x01 (GET only) | `020c0616 01070000 4f2701c0 0038f501 00774242` | Device info: contains VID/PID; `02 0c 06 16` matches bcdDevice c616 (firmware version/date?) |
| 2 | 5 | 0x03 (GET+SET) | `8f311c6900` (constant) | Unknown; possibly a register address/value or command register |
| 3 | 33 | 0x03 (GET+SET) | read stalls (EPROTO) | Probably a command/response buffer: write first, then read |
MIN/DEF are all zeros, MAX all 0xFF, RES 1, so they say nothing useful.
Selector 2 turned out to be the register-access channel and selector 3 a
buffer channel; see "The real driver" for the full protocol.
### HID mouse
Report descriptor (73 bytes): 5 buttons + 3 padding bits, int16 X, int16 Y,
int8 wheel, int8 AC Pan. There is no report ID, and reports are 7 bytes:
```
byte 0: buttons bit0 left, bit1 right, bit2 middle, bit3/4 side,
bit7 = SCAN BUTTON (not declared in the descriptor)
byte 1-2: dX (int16 LE) byte 3-4: dY (int16 LE) byte 5: wheel byte 6: pan
```
The scan button sits in the "padding" bits, so the kernel's input layer
ignores it. It can only be read through `/dev/hidrawN`. Motion reports stop
when the mouse is lifted (laser lift-off detection). The 16-bit deltas at up
to ~125 Hz give a position track that can seed the frame stitching.
### Tooling
- `myscan/xu.py`: extension-unit register access and the scan-mode state machine.
- `myscan/frame.py`: JPEG header fix plus embedded sensor-record decoder.
- `tools/scantest.py`: enter scan mode, grab frames, restore mouse mode.
- `tools/probe.py` records raw frames and HID reports side by side (with
`frames.csv` / `hid.csv` timelines). Run it as
`.venv/bin/python tools/probe.py -d 60 -o capture`.
- `capture/` holds a 60 s recording: resting on paper, sliding over text,
button presses, and lifted toward a window.
### Container passthrough (Incus, container `ai`)
```sh
incus config device add ai myscan-usb usb vendorid=274f productid=c001 mode=0666
incus config device add ai myscan-video4 unix-char source=/dev/video4 path=/dev/video4 mode=0666 required=false
incus config device add ai myscan-video5 unix-char source=/dev/video5 path=/dev/video5 mode=0666 required=false
incus config device add ai myscan-hidraw unix-char source=/dev/hidraw8 path=/dev/hidraw8 mode=0666 required=false
```
The node numbers can change when the mouse is replugged.
---
## The real driver: Dacuda Scanner Mouse for Mac 2.4
- **Source:** the Wayback Machine has `Dacuda-ScannerMouse-Mac-V2.4.0.411.dmg`
(484 MB, archived 2016-05-23), linked from Dacuda's archived "scanner mouse
get started" page. The matching Windows `Dacuda-ScannerMouse-Win-V2.4.0.413.exe`
was never archived. The DMG is saved in `vendor/`, with the unpacked app in
`vendor/mac/payload/Scanner_Mouse.app`.
- **Key binaries:**
- `Contents/Frameworks/UVCControl.framework`: Omnivision's camera-control
library (author path `/Developer/usr/hslee4/UVCControl_110619/`), with
full symbols, fat x86_64/i386/ppc. Classes `UVCCameraControl` and
`CameraControlAppDelegate`, C API `OVIF_*`, plus `DaMouseCIF.m`.
- `Contents/MacOS/Scanner Mouse` (i386 only, 33 MB, Qt5): the app. Its
hardware layer is `Hal::OVMouseManager`
(`libs/Hal/Driver/LuckyCat/OVMouseManager.cpp`), used through `HalFree`.
### UVCControl.framework: the transport layer
All requests are class requests to interface 0, unit 6
(`bmRequestType 0x21/0xA1`, `wIndex 0x0600`):
| Method | Request |
|---|---|
| `UVCWriteUSBReg:withValue:` | SET_CUR sel 2 `[0x00, reg, reg>>8, val, 0]` |
| `UVCReadUSBReg:withValue:` | SET_CUR sel 2 `[0x80, reg, …]`, then GET_CUR sel 2; value = byte 3 |
| `UVCWriteSensorReg:withValue:` | SET_CUR sel 2 `[0x01, reg, reg>>8, val, 0]` |
| `UVCReadSensorReg:withValue:` | SET_CUR sel 2 `[0x81, reg, …]`, then GET_CUR; value = byte 3 |
| `UVCWriteBuf:` / `UVCReadBuf:` | SET_CUR / GET_CUR sel 3, variable length |
| `UVCGetExtensionInfo:` | GET_CUR sel 1 |
`OVIF_SCCBRead/Write(id, reg, …)`: **id `0x88` is a pseudo-ID meaning "bridge
registers"**. Any other id is a real SCCB slave: the library first writes
bridge register `0xF1 = id`, then uses the sensor-register commands.
`OVIF_GetScanBtnStatus` reads bridge register `0xBC`, bit 0.
### Mouse-controller registers (bridge registers, the app's "SCCB 0x88")
| Reg | Use in the app |
|---|---|
| `D8` | Command/status: `00`→`FF` disable mouse (standby); `E1`→`99` start scan streaming; `E0`→`11` enable mouse; `ED`/`EC` security check (`performSecurityCheck`, challenge/response via `A1`–`A5`) |
| `2D` | Written `0x60` before starting a scan (default `0x20`) |
| `E7` | Written `0x28` before starting a scan (default already `0x28`) |
| `E6` | Exposure / LED on-time: `floor(ms × 49.23 + 0.5)` (`OVMouseManager::setExposureTime`) |
| `DC` | Laser readout position (`setLaserReadPosition`; refused while streaming) |
| `BC` | Bit 0 = scan button |
| `BD`/`BE`/`B8` | Proxy to the OV image sensor (SCCB 0x42): `BD` = register, `BE` = value, `B8` = 1 write / 2 read, with a status poll before |
Image-sensor registers used (via the proxy): `0x00` gain, `0x01`–`0x03` colour
gains, `0x16`/`0x19` window position (`setYBaseOffset`). These follow the
standard OV76xx/77xx register map.
### Embedded sensor record (per frame)
`OVJpegSrcPreProc` and `DaReadMouseData` in UVCControl. In scan mode the
firmware hides a 20-byte record in each JPEG's headers:
- SOF0 height/width bytes (4 bytes) carry: frame counter, plus 3 "mode" bytes.
The app restores them to height 296, width 640.
- Entries 49–63 (offset `0x36..0x44` from the `FF DB` marker) of the
**second** DQT carry 15 bytes. The app restores them to `0x19`.
- Each payload byte is decoded with `OVSPCal` using a 2-bit mode from the
SOF bytes and one bit from the first three DQT bytes. Implemented in
`myscan/frame.py`.
Record layout after decoding (field names from app strings
`laser1Dx/laser1Dy/laser1Sq/laser2…`):
| Bytes | Content |
|---|---|
| 0 | frame counter (increments by 1 per frame; verified) |
| 2–3, 4–5 | big-endian 16-bit: laser 1 dX, dY (presumably) |
| 8–9, 10–11 | big-endian 16-bit: laser 2 dX, dY (presumably) |
| 14, 15 | laser 1 / laser 2 surface quality (≈155–164 on fabric; verified to vary) |
| 16 | unknown |
| 17 | 5 flag bits (buttons / state) |
| 19 | set by the app when a frame was dropped (counter gap) |
In mouse mode the headers are plain (480×640, standard tables), so the record
decodes to constants.
---
# Original analysis of the supplied installer
## Device background
- **Product:** Hama myScan, Hama article no. 00052343. It is a 4-button, 1200 dpi
laser mouse with a built-in hand scanner (scan area about 2 × 4 cm, scan
resolutions 100/200/320/400 dpi).
- **Real manufacturer:** Dacuda AG (Zürich, Switzerland). Dacuda designed the
scanner-mouse hardware and its "SLAM Scan" stitching/OCR software and licensed
it to other brands.
- **Sister products from the same Dacuda platform:**
- LG LSM-100 "Smart Scan" mouse (sold with the LG Smart Scan software, Windows only)
- probably the IRIScan Mouse (I.R.I.S.)
- Products of this kind use a small CMOS camera to capture frames of the page.
The host software stitches the frames together using the mouse's position.
## The supplied file: `00052343_wxpvista78_v2_3_2.exe`
| Property | Value |
|---|---|
| Size | 2,046,568 bytes |
| Format | PE32, i386, GUI. A plain executable, not an installer archive. |
| Framework | wxWidgets, MSVC 9 (VC90 CRT) |
| Signed by | Dacuda (Comodo code-signing certificate, contact `lionel.roll@dacuda.com`) |
| PDB path | `D:\JENKINS\jobs\OnlineInstallerClient-Master\workspace\ScanMouse\bin\Release\HAM_Scanner_Mouse_Online_Installer.pdb` |
| Window title | "Scanner Mouse Driver Installer" |
### What it does
1. **Lists USB devices.** It opens the host controllers directly (`\\.\HCD%d`)
and walks the hub tree with `DeviceIoControl`, collecting each device's
descriptor (`wVendorID`, `wProductID`, `bDeviceClass`, …). With `-v`
(verbose) it writes the list to `%TEMP%/connectedDevices.txt`. Other
command-line flags are `-d` (developer) and `-h` (help).
2. **Asks the update server about every device.** The function at VA
`0x404c90` loops over **all** connected devices and appends each one to the
query (the `VID=` reference is at `0x404e9d`, `&PID=` at `0x404eba`):
```
http://updater.dacuda.com/onlineinstaller_list?VID=<vid>&PID=<pid>&VID=...
```
The path string `/onlineinstaller_list` is referenced at `0x403baa`.
**There is no local VID/PID filter.** The server decided which driver
matched the hardware.
3. **Downloads the real driver.** It saves the package as `\online.exe` in a
temp directory and runs it. The status text reads "Downloading Scanner
Mouse Driver (incl. 198 recognition languages)...".
If no matching device is found, it shows "Please connect the Scanner Mouse
and run the installer again."
### Conclusion
The executable contains **no scanning protocol, no device IDs and no image
handling**. There is nothing in it to build a Linux driver from.
## Status of download sources
| Source | Result (2026-10-02) |
|---|---|
| `http://updater.dacuda.com/onlineinstaller_list?...` | HTTP 404. The hostname now points at Google hosting (`ghs.googlehosted.com`); the service is gone. |
| `http://www.hama.de/scannermaus` (the download URL given in Hama's docs) | 404 |
| Hama product pages for 00052343 | 404 |
| Wayback Machine (CDX query for `updater.dacuda.com*`) | Archive was "Temporarily Offline". **Retry this.** |
| Web search for driver mirrors or existing Linux work | Nothing found. The LG LSM-100 was only ever supported on Windows. |
## Next steps (original plan; A.1–A.3 are now done, see the top of this file)
### A. Probe the hardware directly (needs the mouse connected)
1. Dump the USB descriptors (install `usbutils` first, then run `lsusb -v -d <vid>:<pid>`).
Look for interfaces besides the standard HID mouse: a vendor-specific class
(0xFF) with bulk/interrupt endpoints, a video-class interface (UVC), or a
second HID interface with large reports.
2. Check whether the scan button changes what the device reports or how it
enumerates.
3. Use pyusb/libusb to read from the extra endpoints and try to capture raw
sensor frames. Then work out the frame format (size, bit depth) by
rendering the frames as images.
4. If frames come through, write a Linux tool that grabs frames together with
the mouse position, stitches them, and optionally runs OCR with Tesseract.
### B. Find the real Windows driver (fastest route if it turns up)
- Retry the Wayback Machine for `updater.dacuda.com/*` and the Hama download pages.
- Look for the LG Smart Scan or IRIScan Mouse software. Being on the same
Dacuda platform, they probably share the USB protocol.
- Any old CD, any copy of `online.exe`, or an installed "Scanner Mouse"
program folder from a Windows machine would do. The protocol could then be
read out of the real driver.
- With a working Windows setup (a VM with USB passthrough is enough), capturing
traffic with Wireshark + USBPcap during a scan would show the protocol
directly.
## References
- Hama myScan manual: https://www.manualslib.de/manual/56025/Hama-116600.html
- Hama myScan review (PL): https://www.videotesty.pl/recenzja/1442/hama-myscan-myszi-skaner-w-jednym/
- Dacuda background: https://www.venturekick.ch/Worldwide-renowned-scanner-mice
- LG LSM-100 review: https://www.computing.co.uk/review/2122872/review-lg-lsm-100-scanner-mouse
- LG Scanner Mouse review: https://www.itpro.com/637726/lg-scanner-mouse-review
## Laser calibration & stitching (2026-10-02, recording scan_text/)
- Record v0/v1 = laser 1 cumulative (a, b) counts, v3/v4 = laser 2; signed 16-bit, wrapping. ~3 counts per frame pixel.
- Frame content shift: dx = -0.022*a - 0.355*b, dy = 0.324*a + 0.002*b (a, b = mean of both lasers).
- Rotation: dtheta ≈ -4.4e-4 rad * (b1 - b2) (fit against ORB rotation over 10-30 frame windows; corr ~0.88).
- At fast motion (~25 px/frame) the lasers disagree (one over-counts by ~40%), so stitch.py checks each step against
frame-to-frame phase correlation, then ECC-refines every frame against the canvas.
- Frames are transposed + mirrored relative to the page; output = flip(transpose(canvas), -1).
- `tools/stitch.py <dir>` -> <dir>/page.png. Full 751-frame session stitches cleanly.