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:
+345
@@ -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.
|
||||
Reference in New Issue
Block a user