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>
16 KiB
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:
- write
D8 = 0x00, pollD8until it reads0xFF("disable mouse", standby) - write
2D = 0x60andE7 = 0x28(each one is read back to verify) - optionally write
E6= exposure (round(ms × 49.23), 0–255 ≈ 0–5.2 ms) - write
D8 = 0xE1, poll until0x99; 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 (
dwMaxVideoFrameBufferSize378,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:
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 withUVCIOC_CTRL_QUERYon/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.pyrecords raw frames and HID reports side by side (withframes.csv/hid.csvtimelines). 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)
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 WindowsDacuda-ScannerMouse-Win-V2.4.0.413.exewas never archived. The DMG is saved invendor/, with the unpacked app invendor/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. ClassesUVCCameraControlandCameraControlAppDelegate, C APIOVIF_*, plusDaMouseCIF.m.Contents/MacOS/Scanner Mouse(i386 only, 33 MB, Qt5): the app. Its hardware layer isHal::OVMouseManager(libs/Hal/Driver/LuckyCat/OVMouseManager.cpp), used throughHalFree.
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..0x44from theFF DBmarker) of the second DQT carry 15 bytes. The app restores them to0x19. - Each payload byte is decoded with
OVSPCalusing a 2-bit mode from the SOF bytes and one bit from the first three DQT bytes. Implemented inmyscan/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
-
Lists USB devices. It opens the host controllers directly (
\\.\HCD%d) and walks the hub tree withDeviceIoControl, 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). -
Asks the update server about every device. The function at VA
0x404c90loops over all connected devices and appends each one to the query (theVID=reference is at0x404e9d,&PID=at0x404eba):http://updater.dacuda.com/onlineinstaller_list?VID=<vid>&PID=<pid>&VID=...The path string
/onlineinstaller_listis referenced at0x403baa. There is no local VID/PID filter. The server decided which driver matched the hardware. -
Downloads the real driver. It saves the package as
\online.exein 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)
- Dump the USB descriptors (install
usbutilsfirst, then runlsusb -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. - Check whether the scan button changes what the device reports or how it enumerates.
- 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.
- 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.022a - 0.355b, dy = 0.324a + 0.002b (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>-> /page.png. Full 751-frame session stitches cleanly.