pieusb 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+ # Temporary file for partial code execution
204
+ tempCodeRunnerFile.py
205
+
206
+ # Ruff stuff:
207
+ .ruff_cache/
208
+
209
+ # PyPI configuration file
210
+ .pypirc
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # Streamlit
218
+ .streamlit/secrets.toml
@@ -0,0 +1 @@
1
+ 3.13
pieusb-0.1.0/DESIGN.md ADDED
@@ -0,0 +1,352 @@
1
+ # pieusb — API & module design
2
+
3
+ Design notes for `pieusb`, a Python library for USB-connected Reflecta (a.k.a. PacificImage)
4
+ film/dia scanners.
5
+
6
+ Earlier revisions of this document aimed at a [python-sane](https://github.com/python-pillow/Sane)
7
+ lookalike. That goal has been dropped. python-sane's surface carries a lot of shape that exists
8
+ for SANE's sake rather than the user's — `init()`/`exit()` lifecycle calls, device name strings
9
+ that have to be parsed back into devices, a `start()`/`snap()` split that this hardware does not
10
+ actually stream across, `get_parameters()` returning a five-tuple, PIL conversions that cannot
11
+ represent a 4-plane RGBI scan. **The API is instead reduced to the smallest thing that does the
12
+ job**: enumerate, configure, scan.
13
+
14
+ The internal *layering* still follows the SANE C backend (`backend/pieusb*.c`), which remains the
15
+ authoritative reference for everything on the wire. Only the public surface changes.
16
+
17
+ ## Target usage
18
+
19
+ ```python
20
+ import pieusb
21
+
22
+ for info in pieusb.available_devices():
23
+ print(info.vendor, info.model)
24
+
25
+ scanner = pieusb.Scanner(pieusb.available_devices()[0])
26
+ scanner.mode = "rgbi"
27
+ scanner.resolution = 5000
28
+ scanner.color_depth = 16
29
+ scanner.tl_x, scanner.tl_y = 0, 0
30
+ scanner.br_x, scanner.br_y = 14200, 9600
31
+
32
+ def on_progress(scanned_lines, total_lines, chunk, plane):
33
+ print(f"{scanned_lines}/{total_lines}")
34
+
35
+ def on_complete(result):
36
+ if result.error:
37
+ raise result.error
38
+ numpy.save("scan.npy", result.image)
39
+
40
+ scanner.scan(progress=on_progress, scan_complete=on_complete) # returns immediately
41
+ scanner.wait() # optional
42
+ scanner.close()
43
+ ```
44
+
45
+ A scan takes minutes at high resolution, so `scan()` is **non-blocking**: it validates the
46
+ options, starts a worker thread and returns. Callers who want the blocking behaviour call
47
+ `wait()`.
48
+
49
+ ## Public API
50
+
51
+ The entire exported surface:
52
+
53
+ | Name | Role |
54
+ |------|------|
55
+ | `available_devices() -> list[DeviceInfo]` | Enumerate connected scanners and INQUIRY each one |
56
+ | `Scanner(info: DeviceInfo)` | Open a device; options as attributes; `scan()`/`cancel()`/`wait()`/`close()` |
57
+ | `DeviceInfo`, `InquiryResponse` | What enumeration hands back |
58
+ | `ScanResult`, `ScanPhase` | What a finished scan reports |
59
+ | `PieusbError` and subclasses | Errors — see [Exceptions](#exceptions) |
60
+
61
+ Deliberately **not** exposed: `init()`, `exit()`, `open()`, device-name strings, `start()`/`snap()`,
62
+ `get_parameters()`, `multi_scan()`, `fileno()`, PIL conversion.
63
+
64
+ ### `available_devices()`
65
+
66
+ Named after pyserial's `comports()` idiom: one call that both finds devices and describes them.
67
+ It returns `DeviceInfo` objects, each holding the live `usb.core.Device` alongside the parsed
68
+ `InquiryResponse`, so a caller can pick one by model, capability, or index and hand it straight
69
+ to `Scanner`. There is no name string and therefore nothing to parse back into a device.
70
+
71
+ This replaces `get_devices()`.
72
+
73
+ ### `Scanner`
74
+
75
+ Constructed directly from a `DeviceInfo` — no `open()` indirection, since `DeviceInfo` already
76
+ carries everything needed. The constructor opens the USB interface and builds the option table
77
+ from the device's own INQUIRY response.
78
+
79
+ - **Options are attributes.** `scanner.resolution = 5000` goes through `__setattr__`, which
80
+ type-checks and validates against the constraints derived from INQUIRY, raising `TypeError` /
81
+ `ValueError` immediately on a bad value. Reading an option returns its current value.
82
+ `scanner.options` exposes the underlying table for introspection (name, type, unit, default).
83
+ Setting an option while a scan is running raises `ScanInProgress`.
84
+ - **Nothing reaches the device until `scan()`.** Options are host-side state. This mirrors the C
85
+ backend exactly: `sane_control_option()` only writes into `scanner->val[]`, and `sane_start()`
86
+ (`pieusb.c:865`) pushes the whole configuration to the device in one sequence.
87
+ - **`scanner.phase -> ScanPhase`** is readable at any time, so a caller can tell warm-up from
88
+ calibration from the pixel read without waiting for a callback.
89
+ - **`scanner.scanning -> bool`**.
90
+
91
+ #### Methods
92
+
93
+ ```python
94
+ def ready(self) -> bool
95
+ def wait_ready(self, timeout_s: float = 180) -> None
96
+ def scan(self, progress=None, scan_complete=None) -> None
97
+ def cancel(self) -> None
98
+ def wait(self, timeout: float | None = None) -> bool
99
+ def close(self) -> None
100
+ ```
101
+
102
+ - **`ready()`** asks the device whether it can start a scan right now, via TEST UNIT READY.
103
+ Returns a plain `True`/`False` — every NOT_READY sense maps to `False`, including warm-up. Only
104
+ transport-level failures raise. It is a convenience predicate; `scan()` does its own check and
105
+ does not require you to call this first.
106
+ - **`wait_ready()`** polls `ready()` until the device is ready or the timeout expires, raising
107
+ `WarmingUp` if it was still warming up when time ran out and `DeviceNotReady` otherwise.
108
+ - **`scan()`** checks readiness, validates the whole option table, then starts a worker thread
109
+ and returns immediately. It raises rather than starting a scan that cannot succeed:
110
+ - `WarmingUp` — the lamp is still warming up. Retryable; the caller decides whether to
111
+ `wait_ready()` and try again, or report it and move on.
112
+ - `DeviceNotReady` — the device is reachable but cannot scan (including the case where the
113
+ device itself reports a scan already active, `Pieusb_Scanner_State.scanning`).
114
+ - `ScanInProgress` — *this* `Scanner` already has a worker running. Host-side state, distinct
115
+ from the device-side conditions above.
116
+ - `ValueError` / `TypeError` — invalid option combination.
117
+
118
+ All of these are raised on the *calling* thread before the worker starts, so a scan that cannot
119
+ begin fails at the call site rather than arriving in a callback.
120
+ - **`cancel()`** requests a stop and returns immediately. It does **not** touch the device: it
121
+ sets a flag the worker checks between chunk reads, and the worker issues STOP SCAN and then
122
+ fires `scan_complete` with `result.cancelled` set. Cancellation therefore takes effect at the
123
+ next chunk boundary (≤255 lines), not instantly. Calling `cancel()` when no scan is running is
124
+ a no-op.
125
+ - **`wait()`** blocks until the scan finishes, is cancelled or fails; returns `True` if it
126
+ finished within the timeout.
127
+ - **`close()`** cancels any running scan, waits for the worker, then releases the interface.
128
+
129
+ `__exit__` does exactly what `close()` does. This matters: a `with` block that exits while a scan
130
+ is in flight must not release the USB interface out from under the worker thread.
131
+
132
+ `ready()` and `wait_ready()` talk to the device, so they raise `ScanInProgress` while a worker is
133
+ running — see the threading contract below.
134
+
135
+ ## Exceptions
136
+
137
+ ```
138
+ PieusbError
139
+ ├── TransportError USB/SCSI transaction failed below the command layer
140
+ ├── Timeout a command, or a wait for the device, did not complete in time
141
+ ├── CheckCondition raw SCSI CHECK CONDITION with decoded sense data
142
+ ├── DeviceNotReady device is reachable but cannot start a scan
143
+ │ └── WarmingUp ... specifically because the lamp is warming up (retryable)
144
+ ├── ParamError option values that are individually valid but conflict
145
+ └── ScanInProgress host-side: this Scanner already has a worker running
146
+ ```
147
+
148
+ `ParamError` is what whole-table validation raises — the cross-option checks that per-option
149
+ validators structurally cannot express, such as `tl_x < br_x`. It is distinct from the
150
+ `TypeError`/`ValueError` a single bad assignment raises, because it can only be detected once the
151
+ whole table is known.
152
+
153
+ `CheckCondition` is what the *transport* layer raises: it reports what the device said and
154
+ nothing more, exposing `not_ready` / `warming_up` / `must_calibrate` predicates over the sense
155
+ triple. Translating a sense into `WarmingUp` or `DeviceNotReady` is the `Scanner`'s job, since
156
+ that is an interpretation rather than a property of the transaction — and because
157
+ `DeviceNotReady` also covers non-sense sources such as `Pieusb_Scanner_State.scanning`. The C
158
+ backend splits it the same way: `sanei_pieusb_command()` returns a status, `sane_start()` decides
159
+ what it means.
160
+
161
+ `WarmingUp` subclasses `DeviceNotReady` so that `except DeviceNotReady` catches "cannot scan"
162
+ generally, while a caller who wants to retry can catch `WarmingUp` specifically. That hierarchy
163
+ mirrors the wire: both are SCSI sense key `NOT_READY` (0x02), and warm-up is the single case
164
+ distinguished by code 0x04 / qualifier 0x01 — `sanei_pieusb_decode_sense` maps exactly that
165
+ triple to `PIEUSB_STATUS_WARMING_UP` and every other `NOT_READY` to a generic failure
166
+ (`pieusb_usb.c:387-397`).
167
+
168
+ `ScanInProgress` is deliberately *not* under `DeviceNotReady`: it describes this library's own
169
+ state, not the scanner's, and the two want different handling — one is "wait and retry", the
170
+ other is "you have a bug". It is also why the name avoids "busy", which on this hardware already
171
+ means the SCSI `STATUS_BUSY` code.
172
+
173
+ ### Warm-up is handled in two different places, on purpose
174
+
175
+ - **Before the worker starts**, `scan()` refuses and raises `WarmingUp`. The caller is still on
176
+ the stack and can decide: wait, retry, tell the user, give up.
177
+ - **After the worker starts**, at START SCAN, the worker retries with a bounded backoff rather
178
+ than failing. There is no caller left to decide, and the C backend does the same
179
+ (`pieusb.c:1088-1093`, retrying while the status is `WARMING_UP`). If the retries are
180
+ exhausted, it surfaces as `result.error = WarmingUp` through `scan_complete`.
181
+
182
+ Both references also check up front: `sane_start()` reads the device state and returns
183
+ immediately if `state.warmingUp` (`pieusb.c:908-921`), and the PoC calls `wait_ready()` before it
184
+ does anything else.
185
+
186
+ ### Threading contract
187
+
188
+ `scan()` is non-blocking, which makes threading part of the public contract rather than an
189
+ implementation detail:
190
+
191
+ - **Both callbacks run on the worker thread**, not the caller's. A GUI caller must marshal to its
192
+ own event loop; a callback that blocks stalls the read loop and, on a slow enough callback, can
193
+ stall it long enough for the device to time out.
194
+ - **All device access stays on the worker thread.** pyusb/`UASDevice` is not thread-safe, which
195
+ is why `cancel()` is flag-based rather than issuing STOP SCAN itself.
196
+ - **Exceptions never propagate to the caller** once the worker has started. Anything raised
197
+ inside the scan is caught, STOP SCAN is attempted, and it is delivered as `result.error`. A
198
+ caller that only registers `progress` and never `scan_complete` will therefore *silently* miss
199
+ failures — `wait()` returning is not evidence of success. Check `result.error`.
200
+ - One worker at a time per `Scanner`; a second `scan()` raises `ScanInProgress`.
201
+
202
+ ### `progress` callback
203
+
204
+ ```python
205
+ def progress(scanned_lines: int, total_lines: int, chunk: numpy.ndarray, plane: int) -> None
206
+ ```
207
+
208
+ - `scanned_lines` / `total_lines` count raw scanner lines across the whole read, so
209
+ `total_lines == planes * height`. They reach equality exactly once, at the end.
210
+ - `chunk` is the lines read since the previous call, shape **`(n_lines, pixels_per_line)`**,
211
+ already shading-corrected, dtype matching `color_depth`.
212
+ - `plane` is which channel those lines belong to: `0=R, 1=G, 2=B, 3=I`.
213
+
214
+ **Why `chunk` is single-plane rather than an image strip.** The scanner does not return
215
+ interleaved rows. It returns the planes as sequential blocks — all R lines, then all G, and so on
216
+ — each line prefixed by a two-byte channel tag (`poc:794-818`). So the lines arriving at any
217
+ moment belong to *one* channel, and a chunk of them cannot be assembled into an
218
+ `(n_lines, width, planes)` strip; the other channels for those rows do not exist yet and will not
219
+ until much later in the scan. Delivering `(n_lines, width)` plus a `plane` index is the only
220
+ shape that is both honest about what arrived and useful for a live preview.
221
+
222
+ The 255-line read cap does not align to plane boundaries, so a single device read can straddle a
223
+ channel transition. The library splits such a read by tag and calls `progress` once per plane
224
+ run, so a chunk is always single-plane. This means the callback may fire twice for one device
225
+ read, and `n_lines` varies.
226
+
227
+ ### `scan_complete` callback
228
+
229
+ ```python
230
+ def scan_complete(result: ScanResult) -> None
231
+ ```
232
+
233
+ Fires exactly once per `scan()`, on every outcome — success, cancellation and failure — so it is
234
+ the single place teardown belongs.
235
+
236
+ ```python
237
+ @dataclass(frozen=True)
238
+ class ScanResult:
239
+ image: numpy.ndarray | None # None if cancelled or failed
240
+ width: int # pixels per line, as reported by GET PARAMETERS
241
+ height: int # lines per plane
242
+ planes: int # 4 for rgbi, 3 for rgb, 1 for gray
243
+ mode: str
244
+ color_depth: int # 8 or 16
245
+ resolution: int # dpi actually used
246
+ shading_corrected: bool
247
+ cancelled: bool
248
+ error: Exception | None
249
+ duration_s: float
250
+ ```
251
+
252
+ `width`/`height` come from the device's own GET PARAMETERS mid-scan, not from the requested
253
+ frame, and can differ from what the options asked for — which is why they are reported rather
254
+ than left for the caller to infer. On a cancelled scan `image` is `None` and the partial data is
255
+ discarded; assembling a half-read image would mean returning planes with no data in them.
256
+
257
+ The parameters were collapsed into one `ScanResult` rather than a long positional signature: it
258
+ keeps the callback stable as fields are added, and it is the same frozen-dataclass style the rest
259
+ of the package uses.
260
+
261
+ ### Return value
262
+
263
+ `ScanResult.image` is a numpy array of shape **`(lines, pixels_per_line, planes)`** — i.e.
264
+ `(height, width, planes)`, the standard numpy image convention. `image[y, x, c]` indexes row,
265
+ column, channel.
266
+
267
+ - Plane order is **R, G, B, I**, resolved from the per-line channel tag, not from arrival order.
268
+ - `mode="rgbi"` gives 4 planes, `"rgb"` gives 3, `"gray"` gives 1.
269
+ - dtype is `uint8` at `color_depth=8`, `uint16` at 16.
270
+ - Pixels are flat-field (shading) corrected. Infrared dust removal is a separate, later concern.
271
+
272
+ Returning raw numpy rather than PIL is the honest choice here: a 4-plane RGBI scan has no PIL
273
+ mode, and 16-bit multi-channel is awkward in PIL regardless. Callers who want a PIL image can
274
+ convert the RGB planes themselves.
275
+
276
+ ## Module structure
277
+
278
+ Split along the C backend's module boundaries.
279
+
280
+ | File | Role | C-backend analog | State |
281
+ |------|------|------------------|-------|
282
+ | `__init__.py` | Public API: `available_devices()`, re-export `Scanner`, types, exceptions | `pieusb.c` | partial |
283
+ | `scanner.py` | **`Scanner`** — options-as-attributes, `scan()`/`cancel()`/`wait()`/`close()`, the worker thread and its state machine | `pieusb_specific.c` | partial |
284
+ | `options.py` | `Option`/`Parameter`/`OptionsTable`, the table generator, whole-table validation | (SANE opt descriptors) | exists as `option.py` |
285
+ | `commands.py` | Typed SCSI wrappers: `inquiry`, `set_mode`, `set_scan_frame`, `set_exposure`, `set_highlight_shadow`, `get`/`set_gain_offset`, `get_shading_parms`, `start_scan`, `get_ccd_mask`, `get_scan_parameters`, `get_scanned_lines` | `pieusb_scancmd.c` | scattered in `option.py` |
286
+ | `transport.py` | **`UASDevice`** — SCSI-over-USB transaction layer | `pieusb_usb.c` | done |
287
+ | `protocol.py` | Opcodes, ports, statuses, MODE SELECT constants | (headers) | inline in `transport.py`/`option.py` |
288
+ | `usb.py` | libusb backend + enumeration | `pieusb_usb.c` | exists as `usb_utils.py` |
289
+ | `inquiry.py` | `InquiryResponse` parsing + model/VID-PID tables | `pieusb_specific.c` | done |
290
+ | `postprocess.py` | Shading correction, (later) IR dust removal. Deinterleave by line tag stayed in the worker, which decodes each chunk as it arrives rather than buffering the whole raw read | `pieusb_buffer.c`, `sanei_pieusb_post` | partial |
291
+ | `types.py` | Enums + dataclasses, incl. `ScanResult`/`ScanPhase` | — | partial |
292
+ | `exceptions.py` | `PieusbError` base; `DeviceNotReady`, `WarmingUp`, `CheckCondition`, `TransportError`, `Timeout`, `ScanInProgress`, `ParamError` | — | done |
293
+
294
+ `_device.py` folds into `__init__.py` + `inquiry.py`.
295
+
296
+ ## What the worker does
297
+
298
+ The command sequence is `sane_start()` (`pieusb.c:865-1140`), which is the authoritative order.
299
+ Steps 3 and 8 are reads, not writes — the configuration push is not purely one-directional, and
300
+ `pixels_per_line` from step 3 sizes both the shading read and the CCD mask later.
301
+
302
+ Step 4 runs on the calling thread inside `scan()`, before the worker starts. Everything from
303
+ step 1 onward runs on the worker. The worker checks the cancel flag before each chunked read in
304
+ steps 11 and 12.
305
+
306
+ | Phase (`ScanPhase`) | Steps |
307
+ |---|---|
308
+ | `CONFIGURING` | 1. Refuse if already scanning; READ STATE and check warming-up.<br>2. SET EXPOSURE TIME, SET HIGHLIGHT SHADOW.<br>3. **GET SHADING PARMS** → `pixels_per_line`, `n_lines`.<br>4. **Validate the option table as a whole.**<br>5. SET SCAN FRAME.<br>6. `cmd_17`, on slide-transport models that support it.<br>7. GET GAIN OFFSET → SET GAIN OFFSET (read-modify-write).<br>8. SET MODE.<br>9. SLIDE INIT, on slide-transport models. |
309
+ | `WARMING_UP` | 10. START SCAN, retrying while the device reports warming up. |
310
+ | `CALIBRATING` | 11. Read the shading reference, then the CCD mask, then GET PARAMETERS for authoritative width/lines. |
311
+ | `SCANNING` | 12. Read `planes × height` tagged lines in ≤255-line chunks, splitting each read by channel tag, shading-correcting it and firing `progress`. |
312
+ | `PROCESSING` | 13. Assemble the planes into the final array. |
313
+
314
+ Step 4 is where the cross-option checks live that per-option validators structurally cannot
315
+ express: `tl < br` on both axes, the frame within the device's reported bed, `sharpen` versus
316
+ `fast_infrared`. The C backend does the same, in `sanei_pieusb_analyse_options` at the same point
317
+ in the sequence. Getting the frame wrong has already caused one carriage crash in this project,
318
+ so this must abort before the device is touched — hence running it on the calling thread, where
319
+ it can raise.
320
+
321
+ Note that the C backend performs the entire read inside `sane_start()`; `sane_read()` only copies
322
+ out of an already-filled buffer (`pieusb.c:1363`). There is no streaming mode in this hardware,
323
+ which is the other reason a `start()`/`snap()` split buys nothing here.
324
+
325
+ ## Open design questions
326
+
327
+ - **Nothing reports progress before the pixel read.** `progress` only fires during `SCANNING`,
328
+ because it is defined in terms of image lines. But `CONFIGURING` through `CALIBRATING` includes
329
+ lamp warm-up, which can take minutes on a cold scanner, and `scanner.phase` has to be polled to
330
+ observe it. If that turns out to be inadequate, the options are a separate `on_phase` callback
331
+ or a phase argument on `progress`.
332
+ - **Frame coordinate units.** The wire format is native-resolution units (10000 dpi on the
333
+ ProScan 10T), independent of the requested scan dpi. The option table currently exposes those
334
+ raw units. Exposing millimetres instead would be friendlier and is what the C backend's options
335
+ do, at the cost of a conversion layer. Unresolved; see `TODO.md` C-14.
336
+ - **`gray` mode.** It maps to `SCAN_COLOR_FORMAT_PIXEL`, so the returned data carries no per-line
337
+ channel tags and the tag-based deinterleave does not apply. `progress`'s `plane` argument is
338
+ meaningless there. Either handle both layouts or reject `gray` until it is implemented.
339
+ - **Auto-exposure / preview pass.** The `auto_exp` option exists but nothing implements it. The C
340
+ backend derives gain/offset from a preview scan (`OPT_CALIBRATION_MODE`); that is a second
341
+ acquisition inside the worker, and it should be decided whether it reports progress separately
342
+ or is folded into `CONFIGURING`.
343
+
344
+ ## References
345
+
346
+ - SANE C backend: `~/backends/backend/pieusb*.c` — `pieusb.c` (entry points, `sane_start`),
347
+ `pieusb_specific.c` (options → device), `pieusb_scancmd.c` (SCSI wrappers),
348
+ `pieusb_buffer.c`, `pieusb_usb.c`. Authoritative for anything on the wire.
349
+ - PoC: `~/sw/pieusb_ref/pieusb_proscan10t_poc.py` — linear, single-device implementation of the
350
+ full open → INQUIRY → configure → scan → numpy flow, verified against a real ProScan 10T.
351
+ Authoritative for what has actually been observed to work; ProScan 10T only.
352
+ - `TODO.md` — current state and the concrete work remaining.