From 3ca78aed19d3e74bcf544dec823405a261caae1c Mon Sep 17 00:00:00 2001 From: geb Date: Tue, 1 Sep 2026 18:40:46 +0800 Subject: [PATCH 1/7] fix: stored dict print-options crashed every job; sweep keeps .gitkeep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build_print_settings()/submit_pdf() accept `PrintOptions | dict | None`: the pipeline passes the job's STORED options — api/print.py stores validate_print_options(...).model_dump() and retry passes job.options — so a truthy stored dict (even the no-options default) raised AttributeError ('dict' object has no attribute 'paper') and failed every dispatch since p16. Dicts are re-validated into PrintOptions; unknown keys are ignored, so older job rows stay loadable. - sweep_stale_uploads() now skips dotfiles: the startup sweep deleted uploads/.gitkeep (tracked only to keep the empty directory in git), silently dropping uploads/ from the checkout after the first run. - docs: roadmap Section 3 notes the dotfile exception and the Phase 7 note records the regression; SOURCE_OF_TRUTH file map says uploads/ is swept except dotfiles. - tests: 4 new regression tests (stored dict accepted / values kept / empty dict / .gitkeep survival); 267 pass, coverage 96.5% (gate 90%). --- app/printer/windows.py | 16 +++++++++++++--- app/services/uploads.py | 16 +++++++++++----- docs/MULTI_FORMAT_PLAN.md | 10 +++++++++- docs/SOURCE_OF_TRUTH.md | 2 +- tests/unit/test_print_options.py | 21 +++++++++++++++++++++ tests/unit/test_uploads.py | 13 +++++++++++++ 6 files changed, 68 insertions(+), 10 deletions(-) diff --git a/app/printer/windows.py b/app/printer/windows.py index 0792ab4..d814d6d 100644 --- a/app/printer/windows.py +++ b/app/printer/windows.py @@ -151,17 +151,27 @@ def printer_ready(printer_name: str) -> tuple[bool, str]: } -def build_print_settings(options: PrintOptions | None) -> str | None: +def build_print_settings(options: PrintOptions | dict | None) -> str | None: """The -print-settings value for these options — None when nothing is requested, which keeps the no-options command byte-identical to the T4-proven one. + `options` arrives as a PrintOptions model from unit callers, but as a + plain dict from the pipeline (the job's stored print-options JSON — + api/print.py stores `model_dump()`, retry passes `job.options`), so + dicts are validated back into the model here. Unknown/extra keys are + ignored by Pydantic's default config, which also makes this tolerant + of older job rows written by earlier versions. + Precedence: the request's paper beats the PAPER_SIZE config; "fit" rides along only when a paper size is named (it prevents clipping when page and paper disagree) — copies/pages/monochrome alone must not rescale a document that would have printed 1:1. """ - options = options or PrintOptions() + if options is None: + options = PrintOptions() + elif isinstance(options, dict): + options = PrintOptions.model_validate(options) tokens: list[str] = [] paper = (options.paper or PAPER_SIZE).strip().lower() @@ -224,7 +234,7 @@ def cancel_spooler_jobs(printer_name: str, job_id: str) -> int: def submit_pdf( pdf_path: Path, printer_name: str | None = None, - options: PrintOptions | None = None, + options: PrintOptions | dict | None = None, ) -> tuple[str, str]: """Print a PDF file. Returns (method_used, printer_name). diff --git a/app/services/uploads.py b/app/services/uploads.py index 3d8afed..960632d 100644 --- a/app/services/uploads.py +++ b/app/services/uploads.py @@ -22,8 +22,9 @@ Anything still in uploads/ when the service starts is stale (the previous run died before cleanup), so the app sweeps it on startup — the cheap insurance SOURCE_OF_TRUTH Section 8 asks for. uploads/ is service-managed -(every name in it is server-generated), so the sweep now removes ANY file, -not just PDFs. +(every name in it is server-generated), so the sweep removes any file — +except dotfiles like .gitkeep, which only exist to keep the (empty) +directory tracked in git and must survive cleanup. """ import uuid @@ -186,12 +187,17 @@ def save_upload(data: bytes, ext: str = ".pdf") -> tuple[str, Path]: def sweep_stale_uploads() -> int: - """Delete leftovers from a previous run. Returns how many were removed.""" + """Delete leftovers from a previous run. Returns how many were removed. + + Dotfiles (".gitkeep" and friends) are kept — they exist only so git + tracks the empty uploads/ directory in the repo; they are not job + leftovers and must survive every sweep. + """ ensure_upload_dir() removed = 0 for stale in UPLOAD_DIR.iterdir(): - if not stale.is_file(): - continue # directories (or oddities) are skipped, not deleted + if not stale.is_file() or stale.name.startswith("."): + continue # directories (or oddities) and dotfiles are skipped, not deleted try: stale.unlink() removed += 1 diff --git a/docs/MULTI_FORMAT_PLAN.md b/docs/MULTI_FORMAT_PLAN.md index 55d4f2e..889b49f 100644 --- a/docs/MULTI_FORMAT_PLAN.md +++ b/docs/MULTI_FORMAT_PLAN.md @@ -75,7 +75,9 @@ The only PDF-specific code: `uploads.py` (validate/save/sweep), `config.py` **`submit_pdf()` untouched**; logging; startup sweep pattern; tests/CI conventions; SumatraPDF itself. - **Modify (surgical):** `uploads.py` (generic validation, real extension, - sweep everything), `config.py` (format-related settings), `pipeline.py` + sweep everything — post-p16: dotfiles like `.gitkeep` survive, they only + keep the empty directory tracked in git), `config.py` (format-related + settings), `pipeline.py` (detect → processor → submit; real `converting`/`printing` states; conversion lock), `models/printing.py` (+`converting`, +`format` field), `api/print.py` + `api/web.py` (generic messages, wider accept list). @@ -274,6 +276,12 @@ AV scanning — ⚪ v2+ options. job (JSON column + migration for older DBs) and reused by retry, web page gains the collapsible print-options dialog, `spike_t5 --paper long-bond` ready for the 8.5×13 check. + **Fixed post-p16:** the pipeline hands `build_print_settings()` the + job's STORED options — a plain dict (`api/print.py` stores + `model_dump()`; retry passes `job.options`) — and a truthy dict used to + crash it with AttributeError, failing every job, options chosen or not. + Dicts are now re-validated into `PrintOptions`; unknown keys are + ignored, so older job rows stay loadable. Each phase: ruff + pytest + ≥90 % coverage gate; README + SOURCE_OF_TRUTH updated; one commit per phase (p10, p11, …). diff --git a/docs/SOURCE_OF_TRUTH.md b/docs/SOURCE_OF_TRUTH.md index c8773f4..5fb351d 100644 --- a/docs/SOURCE_OF_TRUTH.md +++ b/docs/SOURCE_OF_TRUTH.md @@ -379,7 +379,7 @@ printerService/ ├── tests/ # pytest suite: unit/ (logic, OS faked) + api/ (via TestClient) │ └── conftest.py # Shared fixtures: fresh job store, temp uploads/, fake win32print ├── .github/workflows/ci.yml # GitHub Actions: ruff + pytest (+ coverage gate) on every push/PR -├── uploads/ # Temp storage for incoming PDFs (auto-cleaned) +├── uploads/ # Temp storage for incoming files (auto-cleaned; dotfiles like .gitkeep survive the sweep) ├── logs/ # service.log (rotating, ~1 MB × 3) ├── requirements.txt # Runtime packages: fastapi, uvicorn, python-multipart, pywin32 ├── requirements-dev.txt # Dev packages: pytest, pytest-cov, httpx, ruff diff --git a/tests/unit/test_print_options.py b/tests/unit/test_print_options.py index 4a23c51..4cc1669 100644 --- a/tests/unit/test_print_options.py +++ b/tests/unit/test_print_options.py @@ -111,6 +111,27 @@ def test_full_combination(self, monkeypatch): "paper=letter,fit,2x,collate,2-6,monochrome" ) + def test_stored_dict_options_are_accepted(self, monkeypatch): + # The pipeline passes the job's STORED options — a plain dict + # (api/print.py stores model_dump(); retry passes job.options). + # Regression: a truthy defaults dict used to crash build_print_settings + # with AttributeError ('dict' object has no attribute 'paper') and + # fail EVERY job, options chosen or not. + monkeypatch.setattr(windows, "PAPER_SIZE", "") + stored = {"copies": 1, "pages": "", "paper": "", "color_mode": "color"} + assert build_print_settings(stored) is None + + def test_stored_dict_options_keep_their_values(self, monkeypatch): + monkeypatch.setattr(windows, "PAPER_SIZE", "") + stored = {"copies": 2, "pages": "2-6", "paper": "a4", "color_mode": "monochrome"} + assert build_print_settings(stored) == ( + "paper=A4,fit,2x,collate,2-6,monochrome" + ) + + def test_empty_dict_behaves_like_defaults(self, monkeypatch): + monkeypatch.setattr(windows, "PAPER_SIZE", "") + assert build_print_settings({}) is None + class TestPaperKeyConsistency: def test_every_choice_has_a_layout_and_an_engine_token(self): diff --git a/tests/unit/test_uploads.py b/tests/unit/test_uploads.py index 1d260f6..985fc5f 100644 --- a/tests/unit/test_uploads.py +++ b/tests/unit/test_uploads.py @@ -221,3 +221,16 @@ def test_directory_named_like_a_pdf_never_crashes_the_sweep(self, tmp_upload_dir def test_creates_dir_when_missing(self, tmp_upload_dir): assert sweep_stale_uploads() == 0 assert tmp_upload_dir.exists() + + def test_dotfiles_like_gitkeep_survive_the_sweep(self, tmp_upload_dir): + # .gitkeep exists only so git tracks the (normally empty) uploads/ + # directory in the repo. It is not a job leftover and must survive + # every sweep — regression: the startup sweep used to delete it, + # silently dropping uploads/ from the repository. + tmp_upload_dir.mkdir(parents=True) + (tmp_upload_dir / ".gitkeep").write_bytes(b"") + (tmp_upload_dir / "stale.pdf").write_bytes(b"%PDF-x") + + assert sweep_stale_uploads() == 1 + assert (tmp_upload_dir / ".gitkeep").is_file() + assert not (tmp_upload_dir / "stale.pdf").exists() From 42ffb43c5cd766d4ce7bf7a434e095284df6470d Mon Sep 17 00:00:00 2001 From: geb Date: Tue, 1 Sep 2026 19:36:50 +0800 Subject: [PATCH 2/7] spike: scan S1-S4 all PASS on the L3210; SCAN_PLAN reviewed + results recorded docs/SCAN_PLAN.md: compatibility review section (plan verified against the code: pywin32/Pillow deps, lazy-import + fake-module test pattern, ENABLE_OFFICE kill-switch template, additive main.py wiring); corrections from the review - WIA format GUIDs instead of makepy constants, 201/503 status codes per codebase convention, PIN only on state-changing scan routes, separate scan_jobs table in the same SQLite file; Phase 0 results: S1 plugged + unplugged PASS, S2 flatbed PNG PASS (41.4s @ 200dpi), S3 real ImageProcessor wrap PASS (0.6s), S4 concurrent scan+print PASS (56.1s, ~35% slowdown - Phase 2 sizing input). spike_scan.py: new Phase 0 spike following the T1-T7 convention - S1 WIA detection (never raises; unplugged run proves clean empty result, exit 0), S2 flatbed PNG transfer, S3 wrap via the REAL ImageProcessor, S4 concurrent scan + print (print in a subprocess-only thread, COM on main thread). Unique scan filenames (WIA SaveFile refuses to overwrite, 0x80070050); --dpi/--only/--no-print flags; clean empty result recorded as PASS. --- docs/SCAN_PLAN.md | 413 ++++++++++++++++++++++++++++++++++++++ spike_scan.py | 494 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 907 insertions(+) create mode 100644 docs/SCAN_PLAN.md create mode 100644 spike_scan.py diff --git a/docs/SCAN_PLAN.md b/docs/SCAN_PLAN.md new file mode 100644 index 0000000..ad4cd8d --- /dev/null +++ b/docs/SCAN_PLAN.md @@ -0,0 +1,413 @@ +# Scan Feature — Feasibility, Decision Record & Roadmap + +Status: **approved plan, compatibility-reviewed (§0); Phase 0 COMPLETE — +S1/S2/S3/S4 all PASS on the real L3210 (2026-09-01), including the +unplugged clean-degradation proof. No app code written yet — Phase 1 +(detection-only) is next. Branch `scan-feature`.** +Goal: add an optional **scan** capability (Android → Python service → +Windows → USB → printer's scanner glass → back to phone) to the existing +print service, **without ever affecting printing** on a printer that has +no scanner, and with **zero new required dependencies**. + +Claims are tagged like SOURCE_OF_TRUTH / MULTI_FORMAT_PLAN: +🟢 CONFIRMED FACT · 🔵 RECOMMENDED (decided here) · 🟡 ALTERNATIVE · +🔴 NEEDS TESTING (spike) · ⚪ FUTURE + +--- + +## 0. Compatibility review (2026-09-01) 🟢 + +The plan was checked line-by-line against the code before approval. Result: +**compatible — green light.** Verified claims: + +- `pywin32` is already a runtime dependency (`requirements.txt`, + `sys_platform == "win32"` marker) → `win32com.client` needs no new install. +- Pillow is already in (`pillow>=10.3`) and `app/processors/images.py` has + exactly the reusable fit-to-page logic (`layout()`, `page_size_pt()`, or + simply `ImageProcessor.process()` — the real production path, which is what + the spike uses, the same way T7 used the real `TextProcessor`). +- The lazy-import trick is real (`app/printer/windows.py` imports + `win32print` inside every function) and the test-side mirror exists + (`tests/conftest.py` injects a fake module into `sys.modules`) → the same + pattern works for a fake `win32com` on the Ubuntu CI runner. +- The `ENABLE_OFFICE` kill switch in `app/config.py` is the exact template + for `ENABLE_SCAN`. +- `main.py` mounts routers with plain `include_router` and its lifespan does + sweep + recovery — a scan router and a `downloads/` sweep slot in additively. +- `PrintJob`/`JobStatus` and the print `jobs` SQLite schema are genuinely + print-shaped — the separate-scan-store decision (§4) is confirmed correct. + +Four adjustments were made to this document during review (all resolved here, +so the body below is already corrected): + +1. **WIA constants:** `win32com.client.constants` needs a makepy-generated + module, so the code passes WIA's format GUIDs directly (§2). +2. **Status codes** follow the codebase's existing conventions — 503 for an + unavailable capability (mirrors `/printers`), 201 for an accepted job + (mirrors `/print`) — not the generic 404/409/202 first proposed (§4, §5). +3. **PIN scope** follows `app/services/auth.py`: state-changing routes are + pinned, read-only GETs stay open (§7). +4. **Scan job storage** is pinned down: a separate `scan_jobs` table in the + same SQLite file, in a new module with its own connection + lock — never + touching `jobs.py`'s shared connection (§4). + +--- + +## 1. Executive summary — the 6 answers + +| # | Question | Decision | +|---|----------|----------| +| 1 | Is scanning possible at all? | **Yes** — the L3210 is a flatbed all-in-one, and Windows exposes scanners over a COM API (WIA) already reachable through `pywin32`, a dependency you have. | +| 2 | New required dependency? | **None.** `win32com.client` ships inside `pywin32`. Optional: reuse `Pillow` (already added in p11) to wrap the scanned image into a PDF. | +| 3 | How do we detect "does this printer have a scanner"? | Enumerate Windows' WIA device list at startup/on-demand; a printer with no scanner (or a machine with WIA unavailable) simply returns an empty list — never an error, never a crash. | +| 4 | Does this touch the print code path? | **No.** New files only (`app/scanner/`), new routes only, one additive block in `main.py`. `app/printer/windows.py` and the whole print pipeline stay byte-for-byte unchanged. | +| 5 | What if there's no scanner? | The `/scanners` endpoint returns `[]`, the web page's Scan section simply doesn't render, and `/print`, `/jobs`, `/health` behave exactly as they do today. This is a hard design constraint, not just a hope. | +| 6 | Output format? | **PDF by default** (consistent with the print side's "one internal format"), with an optional `?format=png` escape hatch for a raw image. | + +--- + +## 2. Is it physically/technically possible? 🟢 + +**Hardware:** the Epson L3210 is not print-only — it's an EcoTank +**all-in-one** with a flatbed CIS scanner (optical resolution up to +1200×2400 dpi, max scan area 216×297 mm / A4), connected over the same +USB 2.0 cable already used for printing. So on *your* printer, the +capability genuinely exists — this isn't a hypothetical. + +**Software path:** Windows exposes scanners through **WIA (Windows Image +Acquisition)**, a COM automation API, the same family of OS-level +machinery that Section 2 of SOURCE_OF_TRUTH.md already leans on for +printing (Windows owns the driver; Python asks Windows to do the work). +Concretely: + +```python +import win32com.client # already available — part of pywin32 + +# NOTE (compatibility review): win32com.client.constants requires a +# makepy-generated module (gencache), which may not exist — so we avoid it +# entirely and pass WIA's format GUIDs directly. EnsureDispatch additionally +# generates the constants module if named constants are ever preferred. +WIA_FORMAT_PNG = "{B96B3CAB-0728-11D3-9D7B-0000F81EF32E}" # wiaFormatPNG + +device_manager = win32com.client.EnsureDispatch("WIA.DeviceManager") +for info in device_manager.DeviceInfos: + if info.Type == 1: # 1 == scanner device type in WIA + device = info.Connect() + item = device.Items(1) # the flatbed + image = item.Transfer(WIA_FORMAT_PNG) + image.SaveFile(r"C:\path\out.png") +``` + +This mirrors your printing architecture almost exactly, just reversed: + +| Printing (existing) | Scanning (proposed) | +|---|---| +| Python → `win32print` → Windows spooler → driver → USB → paper out | Python → `win32com` (WIA) → Windows imaging service → driver → USB → image in | +| `pywin32`, lazily imported inside `app/printer/windows.py` | `pywin32`, lazily imported inside new `app/scanner/windows.py` | +| Windows owns color/paper-handling complexity | Windows owns sensor calibration/driver complexity | + +**Constraint check against SOURCE_OF_TRUTH §18:** constraint #11 says +prefer the OS's existing printer/driver system over raw USB control. WIA +*is* that OS-level system for imaging devices — same philosophy, same +justification (Epson's driver already solves calibration correctly; you'd +gain nothing and risk a lot by talking to the scanner's raw USB protocol +yourself). No constraint is violated; this is architecturally the same +decision as "let Windows print, don't touch libusb," applied to scanning. + +**Verdict:** 🟢 feasible, 🔵 recommended approach is WIA via `pywin32`, +no new required dependency. + +--- + +## 3. Capability detection — the core requirement you asked for 🔵 + +This is the part that makes the feature safe to ship to printers/setups +that can't scan, so it gets its own section. + +### 3.1 What "detection" means here + +A **scanner-capable device** = a WIA `DeviceInfo` entry whose `Type` +property equals `1` (WIA's scanner device type constant). Detection asks +Windows "what imaging devices do you currently see," not "does this +specific printer model support scanning" in the abstract — which is +actually more useful for you: it reflects reality on the exact PC, right +now (driver installed or not, USB plugged in or not), the same way +`GET /printers` already reflects live `win32print` state rather than a +hardcoded model list. + +### 3.2 Detection algorithm 🔵 + +``` +1. Try to create WIA.DeviceManager via win32com.client. + - Any exception (pywin32 missing, WIA service disabled, COM error) + → capability = False, reason recorded, NO crash, NO effect on /print. +2. Enumerate DeviceInfos. For each entry, read Type. + - No entries at all → capability = False ("no imaging devices found"). + - Entries exist but none have Type == 1 → capability = False + ("device present but not a scanner" — e.g. only a webcam). + - At least one Type == 1 → capability = True, collect Name/DeviceID + for each. +3. (Best effort) Try to match a scanner's Name against your configured + printer name (e.g. both containing "L3210") so a future multi-printer + setup doesn't offer to scan on the wrong device. On a single-printer + home-lab setup this match is cosmetic — falls back to "list whatever + WIA reports" if no match is found. +4. Cache the result for the process lifetime (like a startup check), but + expose it live via GET /scanners so unplugging/replugging the USB + cable is reflected without restarting the service — re-run the probe + on each call; it's cheap (COM enumeration only, no actual scan). +``` + +### 3.3 Where this lives 🔵 + +New file `app/scanner/windows.py`, structured exactly like +`app/printer/windows.py`: + +- `win32com.client` imported **lazily inside functions**, not at module + top level — this is the same trick SOURCE_OF_TRUTH §13 already credits + for making CI possible on the Ubuntu runner without a real Windows + printer; it does the same job here for WIA. +- `list_scan_devices() -> list[ScanDevice]` — never raises; catches + everything and returns `[]` on any failure, with the failure reason + logged (not surfaced as an HTTP error). +- `scan_available() -> bool` — thin wrapper, `bool(list_scan_devices())`. + +### 3.4 Feature flag, matching the office kill-switch pattern 🔵 + +`ENABLE_SCAN=1` in `.env` (default on), mirroring `ENABLE_OFFICE` from +the multi-format work: a hard "off" switch independent of hardware +detection, so you can disable the *feature* (e.g. while testing) without +unplugging anything. Both gates are ANDed: scanning is offered only when +`ENABLE_SCAN` is true **and** `scan_available()` is true. + +### 3.5 Guarantee to the print path 🔵 + +- No file under `app/printer/` is modified. +- No file under `app/services/pipeline.py` (the print job pipeline) is + modified. +- `main.py` gets one additive `app.include_router(scan_router)` line — + if that router's own startup probe fails, it still mounts (returning + empty results), it just never breaks app startup. +- The existing 193 print/format tests are untouched and stay green; scan + gets its own, separate, test file(s). + +This satisfies your requirement directly: **a printer with no scanner +behaves identically to the service today** — same endpoints, same +behavior, same reliability — it just won't advertise a Scan option. + +--- + +## 4. API design 🔵 + +Kept as a parallel, additive surface next to Section 11 of +SOURCE_OF_TRUTH.md — same conventions (job id, `queued` status, polling). + +| Endpoint | Method | Request | Response | Why | +|---|---|---|---|---| +| `/scanners` | GET | none | `{"available": true/false, "devices": [{"name": "...", "id": "..."}]}` | Mirrors `/printers`; **this is what the web page checks before showing a Scan button at all.** Never errors — `available:false` and `devices: []` is a normal, healthy response on a scanner-less setup. Read-only GET → stays open without a PIN (same convention as `/printers`/`/jobs` in `app/services/auth.py`). | +| `/scan` | POST | optional: `format` (`pdf` default / `png` / `jpeg`), `color_mode` (`color`/`greyscale`), `dpi` (allowlisted values, e.g. 150/200/300) | `201 {"job_id": "...", "status": "queued"}` — same as `/print`; **503** with a clear message when `ENABLE_SCAN=0` or no scanner is detected (mirrors `/printers`' 503, not 404/409) | Starts a scan job; same "accept immediately, work in a background thread" shape as `/print`. PIN required (state-changing — auth.py convention). | +| `/scan/jobs/{id}` | GET | job id | Status (`queued→scanning→done/failed`) + download link when done | Same polling pattern as `/jobs/{id}`. | +| `/scan/jobs/{id}/download` | GET | job id | The scanned file | Phone downloads/opens the result. | +| `/scan/jobs/{id}` | DELETE | job id | Confirmation | Cancel/cleanup, mirrors `/jobs/{id}` DELETE. PIN required (state-changing). | + +**Why a separate `/scan` job table/namespace instead of folding into the +existing print `jobs` table:** the existing store's schema and states +(`received → queued → converting → printing → done/failed/cancelled`) +are print-shaped ("printing" makes no sense for a scan). Keeping scan +jobs in their own small table (or a `direction` column if you'd rather +extend the existing one later) avoids retrofitting print-specific +language onto a fundamentally different job type — consistent with +MULTI_FORMAT_PLAN.md §3's own rule of "isolate behind interfaces, don't +force-fit." (Compatibility review pinned this down: a separate +`scan_jobs` table in the **same** SQLite file (`JOB_DB_PATH`), owned by a +new module `app/services/scan_jobs.py` with **its own connection and its +own `RLock`** — `jobs.py`'s shared connection is never touched, which +keeps the "scan never modifies print code" guarantee literal.) + +--- + +## 5. Scan job lifecycle 🔵 + +States: `received → scanning → done | failed | cancelled` (deliberately +shorter than print's — there's no multi-format conversion step; WIA +either hands back an image or it doesn't). + +1. `POST /scan` → check `ENABLE_SCAN` + `scan_available()` → if either is + false, **503 with a clear message**, not a 500 — this is an expected, + documented state, not an error condition (mirrors `/printers`' 503 when + the OS capability is missing — the 404/409 first proposed didn't match + the codebase's conventions). +2. Create job, return `201 {"job_id": ..., "status": "queued"}` immediately + (the same status code `/print` uses). +3. Background thread: `scanning` → WIA transfer from the flatbed → + `downloads/.` → if `format=pdf` (default), wrap the + transferred image into a single-page PDF using the **same Pillow + fit-to-page logic already built for the image print processor** + (`app/processors/images.py`) — reused, not reinvented. +4. `done` → file kept until downloaded or swept by a cleanup pass (same + pattern as `uploads/`, just a `downloads/` folder). +5. `failed` → common causes: scanner busy/offline, cover open, no paper on + glass (WIA raises a COM error) → map to a human message, same spirit as + the print engine's exit-code mapping in MULTI_FORMAT_PLAN.md §10 Phase 6. + +**Hardware note:** the L3210 is flatbed-only (no ADF), so v1 is +inherently **one page per scan job** — this isn't a corner we're cutting, +it's what the hardware supports. If the printer is ever swapped for one +with an automatic document feeder, WIA reports feeder capability +separately (`WIA_DPS_DOCUMENT_HANDLING_CAPABILITIES`) and multi-page +scanning becomes a natural ⚪ future extension, not a redesign. + +--- + +## 6. Android / web side 🔵 + +Same philosophy as SOURCE_OF_TRUTH §6 Option B (mobile web page served by +FastAPI itself — no app, no Android-specific code): + +- On page load, the web page calls `GET /scanners`. +- If `available: false` → **the Scan section simply isn't rendered.** + No greyed-out button, no "not supported" banner cluttering the UI for + the common case — a printer without a scanner just looks like today's + print-only page. +- If `available: true` → a "Scan" button appears alongside the existing + file-picker/Print button, triggers `POST /scan`, polls + `/scan/jobs/{id}` the same way the existing page presumably polls + print job status, and shows a "View/Download scan" link on completion. + +--- + +## 7. Security 🔵 + +Same posture as SOURCE_OF_TRUTH §8 — sensible home-lab defaults, not +enterprise hardening: + +- LAN-only, same PIN gate as print (`app/services/auth.py`), scoped by the + codebase's existing convention: **pinned** on the state-changing routes + (`POST /scan`, `DELETE /scan/jobs/{id}`), **open** on read-only GETs + (`/scanners`, `/scan/jobs/{id}`) — the web page must be able to ask + "should the Scan section render at all?" without knowing a PIN. +- `dpi` and `color_mode` validated against a **strict allowlist**, not + passed through raw — mirrors the print side's Phase 7 rule ("strict + allowlist regex before it touches a command line") applied here to WIA + property values instead of a Sumatra command line. +- Scanned files get **server-generated filenames** in `downloads/`, same + anti-path-traversal reasoning as `uploads/`. +- `downloads/` gets the same startup-sweep-of-leftovers treatment as + `uploads/`. +- No new attack surface beyond what already exists: still no internet + exposure, still nothing beyond SQLite, still no auth beyond PIN/LAN. + +--- + +## 8. Phased roadmap 🔵 + +Following the same Phase-0-spike-first convention as MULTI_FORMAT_PLAN.md +§10/§14 — hardware truth before code, paper/glass truth before "done." + +### Phase 0 — hardware spike (run on the actual print-server PC) 🔴 + +- **S1 — Detection.** Run the WIA enumeration snippet from §2 standalone. + **PASS =** the L3210 appears with `Type == 1`. Also run it with the + printer's USB unplugged, or (if convenient) on a machine with no + scanner at all, to confirm detection returns an empty list cleanly + instead of throwing — this is the spike that directly proves your + "must not affect printing when absent" requirement. +- **S2 — Single scan.** Transfer one flatbed page to PNG via WIA. + **PASS =** a real, legible image file is produced. +- **S3 — PDF wrap.** Feed S2's PNG through the existing image + fit-to-page Pillow logic. **PASS =** a valid single-page PDF that + opens correctly. +- **S4 — Concurrent-with-print sanity check.** Confirm a scan job and a + print job don't collide over the same USB device/spooler state (likely + fine since they're different Windows subsystems, but worth one real + test given both share one USB cable to one physical unit). + +### Phase 1 — detection only (no scanning yet) + +`app/scanner/windows.py` (`list_scan_devices`, `scan_available`), +`GET /scanners`, `ENABLE_SCAN` flag, web page conditionally shows/hides +the Scan section. **Ships something real and testable — "the page +correctly hides Scan on a scanner-less setup" — before any scanning code +exists at all.** + +### Phase 2 — basic scan pipeline + +`POST /scan` (flatbed, default resolution/color only, PDF output), +job lifecycle + `downloads/`, `/scan/jobs/{id}` + download endpoint. + +### Phase 3 — web UI polish + +Scan button, status polling, download/view link — reusing the existing +page's polling pattern rather than inventing a new one. + +### Phase 4 — scan options + +`dpi`, `color_mode`, `format=png|jpeg` escape hatch, all strictly +validated — same spirit as the print side's Phase 7 options work. + +### ⚪ Explicitly future / out of scope for v1 + +- Multi-page/ADF scanning (moot on this exact printer; revisit only if + the hardware changes). +- OCR / searchable-PDF output. +- Any direct USB/raw scanner protocol (rejected for the same reason raw + USB printing was rejected — SOURCE_OF_TRUTH §4). + +--- + +## 9. Testing plan 🔵 + +Mirrors the existing suite's core trick (SOURCE_OF_TRUTH §13): fake the +OS boundary, never touch real hardware in CI. + +| What | How | +|---|---| +| Detection logic: no devices / devices present but none are scanners / one scanner / WIA raising a COM error | Unit tests with a **fake `win32com.client` module** injected into `sys.modules`, same pattern already used for `win32print` | +| `/scanners` never 500s, regardless of what the fake WIA layer does | API test via `TestClient` | +| `/scan` returns a clear 503 (not 500) when `ENABLE_SCAN=0` or no scanner detected | API test | +| Scan job lifecycle transitions, PDF-wrap reuse of the image processor | Unit tests against fakes, same bounded-polling style as the pipeline-threading tests | +| **Regression guard:** the full existing print/format test suite (267 tests on the `scan-feature` branch) still passes unmodified | Just... run it — no change should be needed | + +Same CI gates apply: `ruff check .` + `pytest --cov-fail-under=90`. + +--- + +## 10. Open items requiring the spike (§8 Phase 0) + +**Spike run on the print-server PC — 2026-09-01 (`spike_scan.py`, 200 dpi):** + +- [x] **S1 (plugged-in) PASS** — WIA sees exactly one imaging device: + `name='EPSON L3210 Series' type=1` (scanner) — L3210 name match True. +- [x] **S2 PASS** — flatbed PNG at 200 dpi: 11.4 MB in **41.4 s**, + judged legible on screen. +- [x] **S3 PASS** — the REAL `ImageProcessor` wrapped it into a single-page + 546 KB PDF in **0.6 s** (`%PDF-` magic verified); printed via + SumatraPDF, accepted by the queue. +- [x] **S4 PASS (2026-09-01, two runs).** Run 1: the print half verified on + real paper while the scan ran (the script then failed *saving* the + scan — it reused S2's filename and WIA's `ImageFile.SaveFile` refuses + to overwrite, `0x80070050 ERROR_ALREADY_EXISTS`; the transfer itself + had already completed — a spike-script bug, not hardware). Run 2 + (after the filename fix, `--only s4`): scan 11.4 MB in **56.1 s** + AND the print accepted by the spooler, concurrently, over the one + USB cable. Scan+print together cost ~35 % more than the scan alone + (41.4 s) — a useful Phase 2 sizing input, not a blocker. +- [x] **S1 (unplugged) PASS** — with the printer's USB unplugged, WIA + enumeration returns "WIA sees no imaging devices (clean empty + result, no crash)" and exits 0. That is the formal proof of the + plan's hard constraint: **a scanner-less setup degrades cleanly and + printing is untouched.** (The spike script was also fixed to label + this expected outcome PASS in its summary instead of FAIL.) +- [ ] Decide the final DPI allowlist: 200 dpi took 41.4 s flatbed-to-file + (mostly the sensor pass — expect ~150 dpi to be faster). The scan + job timeout in Phase 2 must be sized against real timings at each + allowlisted DPI. + +--- + +*This document was compatibility-reviewed against the code (§0) and +approved. Phase 0's spike is CLOSED: S1 (plugged + unplugged), S2, S3 and +S4 all PASS on the real L3210 — the scan feature is proven feasible with +zero new dependencies, and a scanner-less setup is proven safe. No app +code has been implemented yet — Phase 1 (detection-only) is the next +slice to build.* \ No newline at end of file diff --git a/spike_scan.py b/spike_scan.py new file mode 100644 index 0000000..6c610ea --- /dev/null +++ b/spike_scan.py @@ -0,0 +1,494 @@ +""" +spike_scan.py — Scan Feature Spike (docs/SCAN_PLAN.md §8 Phase 0, S1–S4) + +Run this ON the print-server PC, from the project root: + + .venv\\Scripts\\python spike_scan.py + +(No extra installs — pywin32 and Pillow ship in requirements.txt.) + +Covers the four scan spikes (SCAN_PLAN §8 Phase 0). Like T1–T7: hardware +truth before code — this script decides whether the scan feature proceeds +to Phase 1. + + S1 — Detection. Enumerate Windows' WIA device list; the L3210 must + appear with Type == 1 (scanner). Then, to prove the "no scanner + must not affect anything" requirement, UNPLUG the printer's USB + and re-run: detection must degrade to a clean empty result, not a + crash. + S2 — Single scan. Transfer one flatbed page to PNG. PASS = a real, + legible image file is produced. + S3 — PDF wrap. Feed S2's PNG through the REAL ImageProcessor — the + exact production path Phase 2 will reuse (same way T7 used the + real TextProcessor). PASS = a valid single-page PDF that opens + correctly. (Optionally printed for the paper smile-check.) + S4 — Concurrent-with-print sanity check. A scan and a print run at the + same time over the same USB cable (different Windows subsystems, + but one physical unit). PASS = both succeed. + +PASS criteria — judge with your eyes where the script cannot see: + [ ] S2: the PNG on screen is a legible scan of the page on the glass + [ ] S3: the PDF opens; the page is correctly oriented, nothing clipped + [ ] S4: paper comes out AND the scan file is complete/legible +Record the results in SCAN_PLAN §10 (like T4–T7 in SOURCE_OF_TRUTH §5) — +they are the Phase 0 acceptance gate. + +Technical note (SCAN_PLAN §0, adjustment 1): WIA format IDs are passed as +GUID strings — win32com.client.constants needs a makepy-generated module +and must not be relied on. +""" + +import argparse +import itertools +import shutil +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +LINE = "=" * 64 + +# WIA constants, passed as raw values so no generated (makepy) constants +# module is ever needed (see module docstring / SCAN_PLAN §0). +WIA_SCANNER_TYPE = 1 # DeviceInfo.Type: 1 = scanner, 2 = camera, 3 = video +WIA_FORMAT_PNG = "{B96B3CAB-0728-11D3-9D7B-0000F81EF32E}" + +# Unique names per transfer: S4 rescans while S2's file is still in the +# same temp dir, and WIA's ImageFile.SaveFile REFUSES to overwrite +# (COM error 0x80070050 ERROR_ALREADY_EXISTS — the original S4 FAIL). +_SCAN_SEQ = itertools.count(1) + + +def banner(text: str) -> None: + print("\n" + LINE) + print(text) + print(LINE) + + +def _prop(obj, name: str, default="?"): + """Read a WIA property by name, never raising (spike = diagnostics).""" + try: + return obj.Properties(name).Value + except Exception: + return default + + +def find_printer() -> str: + """Prefer the L3210 by name, fall back to the Windows default.""" + import win32print + + flags = win32print.PRINTER_ENUM_LOCAL | win32print.PRINTER_ENUM_CONNECTIONS + names = sorted(p[2] for p in win32print.EnumPrinters(flags)) + if not names: + raise RuntimeError("No printers found — is the Epson installed on this PC?") + for name in names: + if "L3210" in name: + return name + return names[0] + + +# --------------------------------------------------------------------------- +# S1 — detection (the spike that guards the whole feature) +# --------------------------------------------------------------------------- + + +def s1_detect() -> tuple[bool, str]: + """Enumerate WIA devices. Returns (scanner_found, detail). + + NEVER raises — any COM/WIA failure is reported as "no scanner", which + is exactly the behavior the production list_scan_devices() must have + (SCAN_PLAN §3.2). This function is the template for it. + """ + try: + import win32com.client + except ImportError as exc: + return False, f"pywin32 not importable ({exc}) — treated as no scanner" + + try: + manager = win32com.client.Dispatch("WIA.DeviceManager") + infos = manager.DeviceInfos + count = infos.Count + except Exception as exc: + return False, f"WIA enumeration failed ({exc}) — treated as no scanner" + + if count == 0: + return False, "WIA sees no imaging devices (clean empty result, no crash)" + + print(f"\n WIA devices found: {count}") + scanners = [] + for index in range(1, count + 1): # WIA collections are 1-based + try: + info = infos.Item(index) + except Exception as exc: + print(f" device {index}: unreadable ({exc})") + continue + name = _prop(info, "Name") + try: + device_type = info.Type + except Exception: + device_type = "?" + marker = " <== SCANNER" if device_type == WIA_SCANNER_TYPE else "" + print(f" [{index}] name={name!r} type={device_type}{marker}") + if device_type == WIA_SCANNER_TYPE: + scanners.append(info) + + if not scanners: + return False, ( + "devices present but none with Type == 1 " + "(e.g. only a webcam) — clean 'not a scanner' result" + ) + + names = [_prop(s, "Name") for s in scanners] + matched = any("L3210" in str(name) for name in names) + return True, f"scanner(s): {names} (L3210 match: {matched})" + + +def connect_first_scanner(): + """Connect to the first WIA scanner and pick a transferable flatbed item. + + Returns (device, item, item_description). Raises RuntimeError with a + phone-user-readable message if nothing works — the same message shape + the scan pipeline will map to a failed job. + """ + import win32com.client + + manager = win32com.client.Dispatch("WIA.DeviceManager") + infos = manager.DeviceInfos + last_error = "no scanner found" + for index in range(1, infos.Count + 1): + try: + info = infos.Item(index) + if info.Type != WIA_SCANNER_TYPE: + continue + device = info.Connect() + except Exception as exc: + last_error = f"could not connect to scanner {index}: {exc}" + continue + try: + items = device.Items + item_count = items.Count + except Exception as exc: + last_error = f"connected but no items: {exc}" + continue + print(f" device items: {item_count}") + for item_index in range(1, item_count + 1): + try: + item = items.Item(item_index) + except Exception: + continue + item_name = _prop(item, "Item Name", f"item {item_index}") + print(f" [{item_index}] {item_name}") + # Flatbed-first: prefer an item that is NOT the feeder. On the + # L3210 (flatbed-only) item 1 is the flatbed; on multi-item + # devices the flatbed usually names itself "Flatbed". + order = sorted( + range(1, item_count + 1), + key=lambda i: "flat" not in _prop(items.Item(i), "Item Name", "").lower(), + ) + for item_index in order: + item = items.Item(item_index) + # Best-effort: force the flatbed source where the driver + # offers it (WIA_DPS_DOCUMENT_HANDLING_SELECT = FLATBED). + try: + item.Properties("Document Handling Select").Value = 1 + except Exception: + pass # flatbed-only, or driver not exposing the property + return device, item, _prop(item, "Item Name", f"item {item_index}") + last_error = "scanner connected but no transferable item" + raise RuntimeError(last_error) + + +def s2_scan_png(out_dir: Path, dpi: int) -> tuple[Path, float]: + """Transfer one flatbed page to PNG via WIA. Returns (path, seconds).""" + _, item, item_name = connect_first_scanner() + print(f" transferring from: {item_name}") + + # Best-effort resolution set — the driver may refuse (then its default + # is used and the spike still tells us the scan works). + for prop_name in ("Horizontal Resolution", "Vertical Resolution"): + try: + item.Properties(prop_name).Value = dpi + except Exception as exc: + print(f" note: could not set {prop_name} to {dpi} ({exc})") + + start = time.monotonic() + image = item.Transfer(WIA_FORMAT_PNG) + out_path = out_dir / f"scan_{dpi}dpi_{next(_SCAN_SEQ):02d}.png" + image.SaveFile(str(out_path)) + elapsed = time.monotonic() - start + return out_path, elapsed + + +# --------------------------------------------------------------------------- +# S3 — PDF wrap (the REAL production path: ImageProcessor) +# --------------------------------------------------------------------------- + + +def s3_wrap_pdf(png_path: Path, out_dir: Path) -> Path: + from app.processors.images import IMAGE_PROCESSOR + + return IMAGE_PROCESSOR.process(png_path, out_dir) + + +# --------------------------------------------------------------------------- +# Print helpers (T7 convention — the service's exact SumatraPDF invocation) +# --------------------------------------------------------------------------- + + +def make_print_pdf(out_dir: Path) -> Path: + """A one-page test document via the REAL TextProcessor.""" + from app.processors.text import TextProcessor + + source = out_dir / "s4_test_page.txt" + source.write_text( + "S4 concurrent spike — this page printed WHILE a scan ran\n" + "on the same USB-connected Epson L3210.\n\n" + "If you are reading this on paper, the print half of S4 survived.\n", + encoding="utf-8", + ) + return TextProcessor().process(source, out_dir) + + +def print_pdf(sumatra: str, pdf_path: Path, printer_name: str) -> None: + result = subprocess.run( + [sumatra, "-print-to", printer_name, "-silent", str(pdf_path)], + capture_output=True, + timeout=180, + ) + if result.returncode != 0: + raise RuntimeError( + f"SumatraPDF exited with code {result.returncode}: " + f"{result.stderr.decode(errors='replace').strip()}" + ) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--dpi", + type=int, + default=200, + help="scan resolution to request (default 200; DPI-allowlist input)", + ) + parser.add_argument( + "--no-print", + action="store_true", + help="skip the paper-touching parts (S3's optional print, all of S4)", + ) + parser.add_argument( + "--only", + choices=("s1", "s2", "s3", "s4"), + default=None, + help="run a single spike (default: all; S1 always runs as the gate)", + ) + args = parser.parse_args() + + banner("SCAN SPIKE (S1–S4) — run this ON the PC the printer is plugged into") + results: list[tuple[str, str, str]] = [] + + try: + from app.printer.windows import find_sumatra + + sumatra = find_sumatra() + except ImportError as exc: + print(f"Cannot import the app ({exc}). Run from the project root:") + print(" .venv\\Scripts\\python spike_scan.py") + return 1 + + printer_name = None + if not args.no_print: + try: + import win32print # noqa: F401 (pywin32 presence check, like T1) + + printer_name = find_printer() + except ImportError: + print("pywin32 is not installed here: pip install pywin32") + return 1 + + # ---------------- S1: detection ---------------- + banner("S1 — DETECTION (WIA device enumeration; must never crash)") + found, detail = s1_detect() + # A clean empty result is a PASS on the unplugged re-run — the whole + # point of S1's second run — so the summary must not label it FAIL. + expected_empty = not found and ("clean" in detail or "not a scanner" in detail) + results.append( + ( + "S1 detection", + "PASS" if (found or expected_empty) else "FAIL", + detail + + ( + "" + if found + else " — clean empty result IS the pass (USB unplugged: " + "no scanner, no crash, no effect on anything)" + ), + ) + ) + print(f"\n -> {detail}") + if not found: + print( + "\n If the printer IS plugged in, this is the bug to investigate.\n" + " If the USB is UNPLUGGED, this clean empty result is exactly the\n" + " S1 PASS the plan asks for: detection degrades, nothing crashes.\n" + " (S2–S4 need the scanner, so they are skipped.)" + ) + _summary(results) + return 0 if expected_empty else 2 + + temp_dir = Path(tempfile.mkdtemp(prefix="spike_scan_")) + try: + # ---------------- S2: single scan ---------------- + png_path = None + if args.only not in (None, "s2"): + results.append( + ("S2 single scan", "SKIP", f"skipped (--only {args.only})") + ) + else: + banner(f"S2 — SINGLE SCAN (flatbed -> PNG @ {args.dpi} dpi requested)") + print(">>> Put a page FACE DOWN on the scanner glass.") + input("Press Enter when ready...") + try: + png_path, elapsed = s2_scan_png(temp_dir, args.dpi) + size_kb = png_path.stat().st_size / 1024 + results.append( + ( + "S2 single scan", + "PASS" if size_kb > 10 else "WARN", + f"{png_path.name}: {size_kb:.0f} KB in {elapsed:.1f}s " + f"-> {png_path} (EYES: legible?)", + ) + ) + except Exception as exc: + results.append(("S2 single scan", "FAIL", str(exc))) + + # ---------------- S3: PDF wrap ---------------- + banner("S3 — PDF WRAP (S2's PNG through the REAL ImageProcessor)") + if png_path is None: + results.append(("S3 PDF wrap", "SKIP", "no S2 image to wrap")) + elif args.only not in (None, "s3"): + results.append( + ("S3 PDF wrap", "SKIP", f"skipped (--only {args.only})") + ) + else: + try: + start = time.monotonic() + pdf_path = s3_wrap_pdf(png_path, temp_dir) + elapsed = time.monotonic() - start + magic_ok = pdf_path.read_bytes()[:5] == b"%PDF-" + size_kb = pdf_path.stat().st_size / 1024 + results.append( + ( + "S3 PDF wrap", + "PASS" if magic_ok else "FAIL", + f"{pdf_path.name}: {size_kb:.0f} KB in {elapsed:.1f}s, " + f"%PDF- magic: {magic_ok} -> {pdf_path}", + ) + ) + if magic_ok and not args.no_print and sumatra and printer_name: + answer = input( + "\n Print the wrapped PDF for the paper smile-check? [y/N] " + ) + if answer.strip().lower() == "y": + try: + print_pdf(sumatra, pdf_path, printer_name) + print(" print accepted — CHECK PAPER (upright, unclipped)") + except Exception as exc: + print(f" print FAILED: {exc}") + except Exception as exc: + results.append(("S3 PDF wrap", "FAIL", str(exc))) + + # ---------------- S4: concurrent scan + print ---------------- + if args.only not in (None, "s4"): + results.append( + ("S4 concurrent", "SKIP", f"skipped (--only {args.only})") + ) + elif args.no_print or not (sumatra and printer_name): + results.append( + ("S4 concurrent", "SKIP", "skipped (--no-print or no print engine)") + ) + else: + _s4_concurrent(args, temp_dir, sumatra, printer_name, results) + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + _summary(results) + return 0 if all(r[1] in ("PASS", "WARN", "SKIP") for r in results) else 2 + + +def _s4_concurrent(args, temp_dir, sumatra, printer_name, results) -> None: + """S4: a scan and a print at the same time over the one USB cable. + + The print runs in a helper thread (subprocess only — never COM, which + stays on the main thread here); the scan runs on the main thread. Both + results are reported independently so one failure doesn't hide the other. + """ + banner( + "S4 — CONCURRENT SCAN + PRINT (one USB cable, two subsystems)\n" + ">>> Leave the SAME page on the glass. A test page will print\n" + ">>> WHILE the scan runs." + ) + input("Press Enter when ready...") + try: + test_pdf = make_print_pdf(temp_dir) + print_error: list[str] = [] + print_done = threading.Event() + + def _print_job() -> None: + # Only subprocess + file I/O here — no COM in this thread. + try: + print_pdf(sumatra, test_pdf, printer_name) + except Exception as exc: # captured, reported after join + print_error.append(str(exc)) + finally: + print_done.set() + + printer_thread = threading.Thread( + target=_print_job, name="s4-print", daemon=True + ) + printer_thread.start() + try: + scan_path, scan_seconds = s2_scan_png(temp_dir, args.dpi) + finally: + printer_thread.join(timeout=200) + + scan_size_kb = scan_path.stat().st_size / 1024 + if print_error: + results.append(("S4 concurrent", "FAIL", f"print: {print_error[0]}")) + elif not print_done.is_set(): + results.append(("S4 concurrent", "FAIL", "print thread timed out")) + else: + results.append( + ( + "S4 concurrent", + "PASS", + f"scan {scan_size_kb:.0f} KB in {scan_seconds:.1f}s " + "AND print accepted — CHECK BOTH (paper out, PNG legible)", + ) + ) + except Exception as exc: + results.append(("S4 concurrent", "FAIL", str(exc))) + + +def _summary(results: list[tuple[str, str, str]]) -> None: + banner("SUMMARY") + for name, status, detail in results: + print(f"[{status:4}] {name}: {detail}") + print( + "\nNow judge what the script cannot see:\n" + " [ ] S2: the PNG is a legible scan of the page on the glass\n" + " [ ] S3: the PDF opens; page upright, nothing clipped\n" + " [ ] S4: paper came out AND the scan file is complete\n" + "\nRecord the results in SCAN_PLAN §10 (like T4–T7 in SOURCE_OF_TRUTH\n" + "§5) — they are the Phase 0 acceptance gate before any scan code." + ) + + +if __name__ == "__main__": + sys.exit(main()) From e039c583f8da043a0d6f092a3c6553e4cdd75c12 Mon Sep 17 00:00:00 2001 From: geb Date: Tue, 1 Sep 2026 19:50:25 +0800 Subject: [PATCH 3/7] scan-p1: WIA scanner detection - GET /scanners, ENABLE_SCAN, conditional web section app/scanner/windows.py: list_scan_devices/scan_available/scanning_supported - lazy win32com.client import inside functions (app bootable without pywin32, fakes testable on CI), never raises (WIA missing, COM error, broken entry all degrade to []), only WIA Type==1 entries count; scanning_supported = ENABLE_SCAN kill switch AND hardware present, the two gates from SCAN_PLAN 3.4. Verified against the real L3210: EPSON L3210 Series detected with its WIA device ID. app/api/scanners.py: GET /scanners returns {available, devices[{name,id}]} and NEVER errors - available=false + empty list is the healthy scanner-less answer (SCAN_PLAN 1); read-only GET stays open per the auth.py convention. app/models/scanning.py: scan's own models, separate from printing's. app/config.py + .env.example: ENABLE_SCAN flag mirroring ENABLE_OFFICE. app/main.py: one additive include_router. app/api/web.py: Scan section rendered by JS only when /scanners reports a scanner - scanner-less setups see the identical print-only page. tests: fake_win32com fixture in conftest (mirror of fake_win32print; injects both win32com and win32com.client since the dotted import needs the parent), 9 unit tests (found/none/not-a-scanner/COM failure/pywin32 missing/broken entry/name fallback/kill switch gates) + 5 API tests pinning the never-500s contract. Suite: 285 passed, 96.7% coverage, ruff clean. Print code untouched: app/printer/, pipeline.py, jobs.py unchanged. --- .env.example | 9 ++++ app/api/scanners.py | 31 ++++++++++++ app/api/web.py | 30 ++++++++++++ app/config.py | 11 +++++ app/main.py | 6 +++ app/models/scanning.py | 27 +++++++++++ app/scanner/__init__.py | 1 + app/scanner/windows.py | 89 ++++++++++++++++++++++++++++++++++ docs/SCAN_PLAN.md | 26 ++++++++-- tests/api/test_scanners_api.py | 43 ++++++++++++++++ tests/conftest.py | 82 +++++++++++++++++++++++++++++++ tests/unit/test_scanner.py | 89 ++++++++++++++++++++++++++++++++++ 12 files changed, 440 insertions(+), 4 deletions(-) create mode 100644 app/api/scanners.py create mode 100644 app/models/scanning.py create mode 100644 app/scanner/__init__.py create mode 100644 app/scanner/windows.py create mode 100644 tests/api/test_scanners_api.py create mode 100644 tests/unit/test_scanner.py diff --git a/.env.example b/.env.example index 3cf78e5..5514657 100644 --- a/.env.example +++ b/.env.example @@ -45,3 +45,12 @@ CONVERT_TIMEOUT_S=120 # Job history database (SQLite, Phase 5). Default: logs/jobs.sqlite3 inside # the project folder. Delete the file to reset job history. # JOB_DB_PATH= + +# ------------------------------------------------------------------ +# Scanning (docs/SCAN_PLAN.md) — optional, additive, never affects print. +# ------------------------------------------------------------------ + +# Scan support is offered only when this is on AND Windows actually sees a +# scanner (WIA). ENABLE_SCAN=0 turns the feature off without unplugging +# anything; the web page hides its Scan section automatically either way. +ENABLE_SCAN=1 diff --git a/app/api/scanners.py b/app/api/scanners.py new file mode 100644 index 0000000..a3006c0 --- /dev/null +++ b/app/api/scanners.py @@ -0,0 +1,31 @@ +"""GET /scanners — what Windows' WIA layer can see (docs/SCAN_PLAN.md §4). + +Mirrors app/api/printers.py in shape, with one crucial difference: this +endpoint NEVER errors. available=false + devices=[] is the normal, healthy +answer on a scanner-less setup — the web page uses it to decide whether to +render the Scan section at all (SCAN_PLAN §1 answer 5). + +Read-only GET → deliberately no PIN (app/services/auth.py convention: +only state-changing routes are pinned). +""" + +from fastapi import APIRouter + +from app.models.scanning import ScannersInfo +from app.scanner.windows import ENABLE_SCAN, list_scan_devices + +router = APIRouter() + + +@router.get("/scanners", response_model=ScannersInfo) +def scanners() -> ScannersInfo: + """List scanners Windows knows about, plus the "offered" flag. + + ENABLE_SCAN (kill switch) AND at least one detected scanner = offered. + Anything else reports available=false and an empty list — the phone + simply never shows a Scan option, exactly like today's print-only page. + """ + devices = list_scan_devices() # never raises (SCAN_PLAN §3.2) + if not (ENABLE_SCAN and devices): + return ScannersInfo(available=False, devices=[]) + return ScannersInfo(available=True, devices=devices) diff --git a/app/api/web.py b/app/api/web.py index 20356b4..6818636 100644 --- a/app/api/web.py +++ b/app/api/web.py @@ -134,6 +134,18 @@ + + + """ diff --git a/app/config.py b/app/config.py index 452531c..6f530ff 100644 --- a/app/config.py +++ b/app/config.py @@ -86,3 +86,14 @@ def _get(name: str, default: str) -> str: # path. Default lives under logs/ (git-ignored). Delete the file to reset # job history. JOB_DB_PATH = _get("JOB_DB_PATH", str(BASE_DIR / "logs" / "jobs.sqlite3")) + +# ------------------------------------------------------------------ +# Scan settings (docs/SCAN_PLAN.md). +# ------------------------------------------------------------------ + +# Scanning (via Windows' WIA) is optional and additive: it is offered only +# when this flag is on AND Windows actually reports a scanner (SCAN_PLAN +# §3.4). Mirrors ENABLE_OFFICE: 0 turns the feature off without unplugging +# anything, and a scanner-less machine simply never offers it at all — +# printing is unaffected either way. +ENABLE_SCAN = _get("ENABLE_SCAN", "1").strip().lower() not in ("0", "false", "no") diff --git a/app/main.py b/app/main.py index 2f6f751..2de0ea1 100644 --- a/app/main.py +++ b/app/main.py @@ -33,6 +33,7 @@ from app.api.jobs import router as jobs_router from app.api.print import router as print_router from app.api.printers import router as printers_router +from app.api.scanners import router as scanners_router from app.api.web import router as web_router from app.services import jobs from app.services.logging_setup import setup_logging @@ -91,3 +92,8 @@ def health(): app.include_router(printers_router) app.include_router(jobs_router) +# GET /scanners (docs/SCAN_PLAN.md Phase 1): additive scan-feature +# discovery. The endpoint never errors — on a scanner-less setup it just +# reports available=false, and nothing else in the app changes. +app.include_router(scanners_router) + diff --git a/app/models/scanning.py b/app/models/scanning.py new file mode 100644 index 0000000..82ea266 --- /dev/null +++ b/app/models/scanning.py @@ -0,0 +1,27 @@ +"""Pydantic models for the scan feature (docs/SCAN_PLAN.md §4). + +Scan keeps its own models, separate from printing's — the same reason it +gets its own job store later: "printing" language doesn't fit a scan, and +the scan feature must never reach into print code (SCAN_PLAN §4). +""" + +from pydantic import BaseModel + + +class ScanDevice(BaseModel): + """One scanner Windows' WIA layer reports (a GET /scanners entry).""" + + name: str + id: str + + +class ScannersInfo(BaseModel): + """The GET /scanners response. + + available=false with an empty devices list is a NORMAL, healthy answer + on a scanner-less setup (SCAN_PLAN §1 answer 5) — the web page uses it + to decide whether to render the Scan section at all. + """ + + available: bool + devices: list[ScanDevice] diff --git a/app/scanner/__init__.py b/app/scanner/__init__.py new file mode 100644 index 0000000..78d8543 --- /dev/null +++ b/app/scanner/__init__.py @@ -0,0 +1 @@ +"""Scanner support (docs/SCAN_PLAN.md) — additive by design, never touches printing.""" diff --git a/app/scanner/windows.py b/app/scanner/windows.py new file mode 100644 index 0000000..babd246 --- /dev/null +++ b/app/scanner/windows.py @@ -0,0 +1,89 @@ +"""Windows scanner detection via WIA (docs/SCAN_PLAN.md Phase 1). + +Shaped like app/printer/windows.py and using its central trick: +win32com.client is imported INSIDE the functions, never at module level. +That keeps the whole app bootable on machines without pywin32 (the Ubuntu +CI runner) and lets tests inject a fake module into sys.modules — the +exact pattern conftest.py's fake_win32print already established. + +The hard rule (SCAN_PLAN §3): detection NEVER raises. Every failure — +pywin32 missing, the WIA service disabled, a COM error, one broken device +entry — is logged and reported as "no scanners". The scan feature must be +invisible where it can't work, and must never be the reason the app breaks. + +WIA facts the code relies on (proven by spike_scan.py on the real L3210, +S1 plugged AND unplugged): + - win32com.client.Dispatch("WIA.DeviceManager") gives the device manager; + - .DeviceInfos is a 1-BASED collection with .Count and .Item(i); + - an entry is a scanner when its .Type == 1; + - the friendly name lives in .Properties("Name").Value, not on an + attribute, so it is read defensively too. +""" + +import logging + +from app.config import ENABLE_SCAN +from app.models.scanning import ScanDevice + +logger = logging.getLogger(__name__) + +# WIA DeviceInfo.Type values (SCAN_PLAN §2): 1 = scanner, 2 = camera, 3 = video. +WIA_SCANNER_TYPE = 1 + + +def list_scan_devices() -> list[ScanDevice]: + """Ask Windows which scanners exist right now. NEVER raises.""" + try: + import win32com.client + + manager = win32com.client.Dispatch("WIA.DeviceManager") + infos = manager.DeviceInfos + count = infos.Count + except Exception as exc: + # Missing pywin32, WIA service disabled, COM blow-up: all mean the + # same thing to this feature — "no scanner on this machine". + logger.warning("WIA scanner detection unavailable: %s", exc) + return [] + + devices: list[ScanDevice] = [] + for index in range(1, count + 1): # WIA collections are 1-based + try: + info = infos.Item(index) + if info.Type != WIA_SCANNER_TYPE: + continue # a webcam/camera must not pose as a scanner + devices.append( + ScanDevice(name=_display_name(info), id=str(info.DeviceID)) + ) + except Exception as exc: + # One unreadable entry must not hide the healthy scanners. + logger.warning("skipping unreadable WIA device %d: %s", index, exc) + return devices + + +def _display_name(info) -> str: + """The device's friendly name, read defensively (COM property access).""" + try: + return str(info.Properties("Name").Value) + except Exception: + return "" + + +def scan_available() -> bool: + """True when Windows reports at least one scanner right now. + + Re-probed on every call (no cache): unplugging the USB cable is + reflected immediately, the same way /printers reflects live win32print + state (SCAN_PLAN §3.2 step 4). Enumeration is COM-only and cheap. + """ + return bool(list_scan_devices()) + + +def scanning_supported() -> bool: + """The two gates ANDed (SCAN_PLAN §3.4): the ENABLE_SCAN kill switch + AND a scanner actually present. This is the single question the + /scanners endpoint (and later /scan) answers. + + ENABLE_SCAN is imported by value from app.config — per conftest rule 2, + tests patch it HERE on this module, not on app.config. + """ + return ENABLE_SCAN and scan_available() diff --git a/docs/SCAN_PLAN.md b/docs/SCAN_PLAN.md index ad4cd8d..689c14c 100644 --- a/docs/SCAN_PLAN.md +++ b/docs/SCAN_PLAN.md @@ -2,8 +2,9 @@ Status: **approved plan, compatibility-reviewed (§0); Phase 0 COMPLETE — S1/S2/S3/S4 all PASS on the real L3210 (2026-09-01), including the -unplugged clean-degradation proof. No app code written yet — Phase 1 -(detection-only) is next. Branch `scan-feature`.** +unplugged clean-degradation proof. Phase 1 (detection) LANDED — +`GET /scanners` live, print code untouched. Next: Phase 2 (scan +pipeline). Branch `scan-feature`.** Goal: add an optional **scan** capability (Android → Python service → Windows → USB → printer's scanner glass → back to phone) to the existing print service, **without ever affecting printing** on a printer that has @@ -330,6 +331,23 @@ the Scan section. **Ships something real and testable — "the page correctly hides Scan on a scanner-less setup" — before any scanning code exists at all.** +**Landed (2026-09-01):** `app/scanner/windows.py` (`list_scan_devices`, +`scan_available`, `scanning_supported` = the two gates ANDed; lazy +`win32com.client` import; never raises, per-entry resilience), +`app/models/scanning.py` (`ScanDevice`, `ScannersInfo` — scan's own +models, separate from printing's), `app/api/scanners.py` +(`GET /scanners`, never errors, no PIN on the read-only GET), +`ENABLE_SCAN` in config + `.env.example`, one additive router mount in +`main.py`, and the web page's Scan section that renders only when +`/scanners` reports a scanner (its placeholder button is replaced in +Phase 3). Tests: a `fake_win32com` fixture in conftest (mirror of +`fake_win32print`; both `win32com` and `win32com.client` injected), +9 unit tests (found / none / not-a-scanner / COM failure / pywin32 +missing / broken entry / name fallback / kill switch) and 5 API tests +pinning the never-500s contract. Suite: 285 tests, 96.7 % coverage, +ruff clean. **Print code untouched** — `app/printer/`, `pipeline.py`, +`jobs.py` byte-for-byte unchanged. + ### Phase 2 — basic scan pipeline `POST /scan` (flatbed, default resolution/color only, PDF output), @@ -408,6 +426,6 @@ Same CI gates apply: `ruff check .` + `pytest --cov-fail-under=90`. *This document was compatibility-reviewed against the code (§0) and approved. Phase 0's spike is CLOSED: S1 (plugged + unplugged), S2, S3 and S4 all PASS on the real L3210 — the scan feature is proven feasible with -zero new dependencies, and a scanner-less setup is proven safe. No app -code has been implemented yet — Phase 1 (detection-only) is the next +zero new dependencies, and a scanner-less setup is proven safe. Phase 1 +(detection) has landed. Phase 2 (the basic scan pipeline) is the next slice to build.* \ No newline at end of file diff --git a/tests/api/test_scanners_api.py b/tests/api/test_scanners_api.py new file mode 100644 index 0000000..222de49 --- /dev/null +++ b/tests/api/test_scanners_api.py @@ -0,0 +1,43 @@ +"""API tests for GET /scanners (docs/SCAN_PLAN.md §4/§9). + +The endpoint's contract: it NEVER errors. Whatever the WIA layer does — +healthy, scanner-less, or on fire — the phone gets a 200 with +{"available": bool, "devices": [{"name", "id"}]}. +""" + + +class TestScannersEndpoint: + def test_scanner_present_is_offered(self, client, fake_win32com): + fake_win32com.add_device(name="EPSON L3210 Series") + response = client.get("/scanners") + assert response.status_code == 200 + body = response.json() + assert body["available"] is True + assert body["devices"][0]["name"] == "EPSON L3210 Series" + + def test_scanner_less_setup_is_a_healthy_false(self, client, fake_win32com): + response = client.get("/scanners") + assert response.status_code == 200 + assert response.json() == {"available": False, "devices": []} + + def test_wia_failure_never_500s(self, client, fake_win32com): + fake_win32com.add_device() + fake_win32com.fail_dispatch(RuntimeError("WIA service disabled")) + response = client.get("/scanners") + assert response.status_code == 200 + assert response.json() == {"available": False, "devices": []} + + def test_kill_switch_hides_the_feature( + self, client, fake_win32com, monkeypatch + ): + fake_win32com.add_device() + monkeypatch.setattr("app.api.scanners.ENABLE_SCAN", False) + response = client.get("/scanners") + assert response.status_code == 200 + assert response.json() == {"available": False, "devices": []} + + def test_response_shape_is_exactly_the_plan(self, client, fake_win32com): + fake_win32com.add_device() + body = client.get("/scanners").json() + assert set(body) == {"available", "devices"} + assert set(body["devices"][0]) == {"name", "id"} diff --git a/tests/conftest.py b/tests/conftest.py index 5a607bd..395d61e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,7 @@ real printer, no real SumatraPDF, no real .env file is ever consulted. """ +import itertools import sys import threading import time @@ -133,6 +134,87 @@ def _set_job(handle, job_id, level, info, command): return fake +@pytest.fixture +def fake_win32com(monkeypatch): + """A stand-in for the WIA automation layer, injected into sys.modules. + + app/scanner/windows.py imports win32com.client INSIDE its functions — + the same trick app/printer/windows.py uses for win32print — so a fake + module is picked up by that import, making scanner detection testable + on any OS (the Ubuntu CI runner has no real pywin32 at all). + + BOTH "win32com" and "win32com.client" are injected: a dotted import + imports the parent package first, so both names must exist. + + Usage: + fake_win32com.add_device() # the L3210 by default + fake_win32com.add_device(name="Cam", wia_type=2) # a camera, not a scanner + fake_win32com.fail_dispatch(RuntimeError("...")) # WIA itself blows up + """ + client = types.ModuleType("win32com.client") + package = types.ModuleType("win32com") + package.client = client + + sequence = itertools.count(1) + state: dict = {"fail": None} + + class FakeDeviceInfos: + def __init__(self): + self.items = [] + + @property + def Count(self): + return len(self.items) + + def Item(self, index): + if not 1 <= index <= len(self.items): + raise IndexError(f"WIA index {index} out of range") + return self.items[index - 1] # 1-based, like the real WIA + + infos = FakeDeviceInfos() + manager = types.SimpleNamespace(DeviceInfos=infos) + + def dispatch(prog_id): + if state["fail"] is not None: + raise state["fail"] + if prog_id != "WIA.DeviceManager": + raise ValueError(f"unexpected ProgID: {prog_id!r}") + return manager + + client.Dispatch = dispatch + + def add_device( + name: str = "EPSON L3210 Series", + wia_type: int = 1, + device_id=None, + no_name: bool = False, + ): + """Add one WIA DeviceInfo. no_name=True simulates a device whose + Properties("Name") read fails (the _display_name fallback path).""" + + def properties(prop_name): + if no_name: + raise RuntimeError(f"no {prop_name} property") + return types.SimpleNamespace(Value=name) + + info = types.SimpleNamespace( + Type=wia_type, + DeviceID=device_id or f"wia-device-{next(sequence)}", + Properties=properties, + ) + infos.items.append(info) + return info + + def fail_dispatch(exc: Exception): + state["fail"] = exc + + monkeypatch.setitem(sys.modules, "win32com", package) + monkeypatch.setitem(sys.modules, "win32com.client", client) + return types.SimpleNamespace( + infos=infos, add_device=add_device, fail_dispatch=fail_dispatch + ) + + class FakePrintResult: """Configurable fake for windows.submit_pdf. diff --git a/tests/unit/test_scanner.py b/tests/unit/test_scanner.py new file mode 100644 index 0000000..dd610b5 --- /dev/null +++ b/tests/unit/test_scanner.py @@ -0,0 +1,89 @@ +"""Unit tests for app/scanner/windows.py (docs/SCAN_PLAN.md §9). + +The real WIA layer was proven on hardware by spike_scan.py (S1: the L3210 +plugged AND unplugged). These tests pin the logic around it: detection +never raises, degrades to [] on any failure, and only Type==1 entries +count as scanners. Same fake-module pattern as test_printer_windows.py. +""" + +import sys + +from app.scanner import windows as scanner_windows + + +class TestListScanDevices: + def test_finds_a_scanner(self, fake_win32com): + fake_win32com.add_device(name="EPSON L3210 Series", device_id="wia-l3210") + devices = scanner_windows.list_scan_devices() + assert [(d.name, d.id) for d in devices] == [ + ("EPSON L3210 Series", "wia-l3210") + ] + + def test_no_devices_means_empty_list(self, fake_win32com): + assert scanner_windows.list_scan_devices() == [] + + def test_devices_without_scanners_mean_empty_list(self, fake_win32com): + # A webcam (Type 2) must not pose as a scanner (SCAN_PLAN §3.2). + fake_win32com.add_device(name="Webcam", wia_type=2) + assert scanner_windows.list_scan_devices() == [] + + def test_only_scanner_type_entries_are_kept(self, fake_win32com): + fake_win32com.add_device(name="Webcam", wia_type=2) + fake_win32com.add_device(name="EPSON L3210 Series") + devices = scanner_windows.list_scan_devices() + assert [d.name for d in devices] == ["EPSON L3210 Series"] + + def test_com_failure_degrades_to_empty_even_with_a_scanner( + self, fake_win32com + ): + fake_win32com.add_device() + fake_win32com.fail_dispatch(RuntimeError("WIA service disabled")) + assert scanner_windows.list_scan_devices() == [] + + def test_missing_pywin32_degrades_to_empty(self, monkeypatch): + # None in sys.modules makes the import itself raise ImportError. + monkeypatch.setitem(sys.modules, "win32com", None) + assert scanner_windows.list_scan_devices() == [] + + def test_unreadable_entry_does_not_hide_healthy_scanners(self, fake_win32com): + class Broken: + @property + def Type(self): + raise RuntimeError("COM boom") + + fake_win32com.infos.items.append(Broken()) + fake_win32com.add_device(name="EPSON L3210 Series") + devices = scanner_windows.list_scan_devices() + assert [d.name for d in devices] == ["EPSON L3210 Series"] + + def test_unreadable_name_falls_back_to_empty_string(self, fake_win32com): + fake_win32com.add_device(no_name=True) + assert scanner_windows.list_scan_devices()[0].name == "" + + def test_device_id_is_stringified(self, fake_win32com): + fake_win32com.add_device(device_id=12345) + assert scanner_windows.list_scan_devices()[0].id == "12345" + + +class TestAvailability: + def test_available_with_a_scanner(self, fake_win32com): + fake_win32com.add_device() + assert scanner_windows.scan_available() is True + + def test_not_available_without_one(self, fake_win32com): + assert scanner_windows.scan_available() is False + + def test_supported_needs_the_flag_and_the_hardware( + self, fake_win32com, monkeypatch + ): + fake_win32com.add_device() + monkeypatch.setattr(scanner_windows, "ENABLE_SCAN", True) + assert scanner_windows.scanning_supported() is True + monkeypatch.setattr(scanner_windows, "ENABLE_SCAN", False) + assert scanner_windows.scanning_supported() is False + + def test_supported_needs_hardware_even_when_enabled( + self, fake_win32com, monkeypatch + ): + monkeypatch.setattr(scanner_windows, "ENABLE_SCAN", True) + assert scanner_windows.scanning_supported() is False From c43c654f35a3cf5114c5881be1591b1377015ea3 Mon Sep 17 00:00:00 2001 From: geb Date: Tue, 1 Sep 2026 20:31:12 +0800 Subject: [PATCH 4/7] scan-p2: basic scan pipeline - POST /scan, scan_jobs store, downloads/, status/download/cancel app/scanner/windows.py: scan_flatbed(dest) - one flatbed page to PNG at driver defaults (options in Phase 4); WIA errors map to phone-readable messages via a HRESULT catalog (busy/offline/jam/cover/locked, raw-text fallback) matching the print side's exit-code catalog; _open_flatbed_item() prefers a flatbed-named item (spike S2-proven shape). app/services/scan_jobs.py: separate scan_jobs table in the same SQLite file with its OWN connection and RLock - app/services/jobs.py's connection is never touched (SCAN_PLAN 0). Lifecycle queued -> scanning -> done/failed/cancelled; startup recovery flips interrupted scans to failed. app/services/downloads.py: downloads/ hygiene - server-generated names, dotfiles survive the startup sweep. app/services/scan_pipeline.py: background daemon thread - WIA transfer -> REAL ImageProcessor (spike-S3 reuse of the print side's fit-to-page code) -> downloads/.pdf, raw PNG deleted on success / kept on failure; cancel checked between every stage, a cancelled scan never marked done. app/api/scan.py: POST /scan (201 + job id, PIN-gated, 503 with actionable message when ENABLE_SCAN=0 or no scanner), GET /scan/jobs/{id} (download_url when done), GET /scan/jobs/{id}/download (FileResponse), DELETE /scan/jobs/{id} (cancel + cleanup, PIN-gated). main.py: downloads sweep + scan recovery in lifespan, one additive router mount. app/models/scanning.py: ScanStatus/ScanAccepted/ScanJob. config.py: DOWNLOAD_DIR. tests: fake_win32com grew a connectable device (Connect -> Items -> transferable item with in-flight entered/gate Events, transfer_error, corrupt_png modes); 14 store/downloads unit tests, 9 pipeline tests (COM-error translation, vanished scanner, corrupt-image wrap failure, cancel-mid-transfer discard), 15 API tests. Suite: 323 passed, 96.0% coverage, ruff clean. Print code untouched. --- app/api/scan.py | 121 ++++++++++++++++++++ app/config.py | 4 + app/main.py | 17 ++- app/models/scanning.py | 41 +++++++ app/scanner/windows.py | 115 +++++++++++++++++++ app/services/downloads.py | 76 +++++++++++++ app/services/scan_jobs.py | 186 +++++++++++++++++++++++++++++++ app/services/scan_pipeline.py | 106 ++++++++++++++++++ docs/SCAN_PLAN.md | 36 +++++- tests/api/test_scan_api.py | 131 ++++++++++++++++++++++ tests/conftest.py | 95 +++++++++++++++- tests/unit/test_downloads.py | 47 ++++++++ tests/unit/test_scan_jobs.py | 90 +++++++++++++++ tests/unit/test_scan_pipeline.py | 153 +++++++++++++++++++++++++ 14 files changed, 1209 insertions(+), 9 deletions(-) create mode 100644 app/api/scan.py create mode 100644 app/services/downloads.py create mode 100644 app/services/scan_jobs.py create mode 100644 app/services/scan_pipeline.py create mode 100644 tests/api/test_scan_api.py create mode 100644 tests/unit/test_downloads.py create mode 100644 tests/unit/test_scan_jobs.py create mode 100644 tests/unit/test_scan_pipeline.py diff --git a/app/api/scan.py b/app/api/scan.py new file mode 100644 index 0000000..20502d8 --- /dev/null +++ b/app/api/scan.py @@ -0,0 +1,121 @@ +"""Scan API (docs/SCAN_PLAN.md §4) — Phase 2: the basic scan pipeline. + + POST /scan — start a scan: 201 + job id; 503 when + disabled or scanner-less (an expected, + documented state, not a 500) + GET /scan/jobs/{id} — poll status; carries the download link + once done + GET /scan/jobs/{id}/download — the finished PDF + DELETE /scan/jobs/{id} — cancel + cleanup (queued or scanning) + +Same shape as the print surface (server-generated job id, accept +immediately, poll) but its own namespace: a scan is not a print job in +either direction (SCAN_PLAN §4). PIN applies to the state-changing routes +only — read-only GETs stay open (app/services/auth.py convention). +""" + +import logging +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse + +from app.models.scanning import ScanAccepted, ScanJob, ScanStatus +from app.scanner.windows import ENABLE_SCAN, scanning_supported +from app.services import downloads, scan_jobs +from app.services.auth import require_pin +from app.services.scan_pipeline import start_scan + +router = APIRouter() +logger = logging.getLogger(__name__) + + +def _gate() -> None: + """The two gates ANDed (SCAN_PLAN §3.4), refused with a clear 503.""" + if not ENABLE_SCAN: + raise HTTPException( + status_code=503, + detail="Scanning is disabled on this server (ENABLE_SCAN=0).", + ) + if not scanning_supported(): + raise HTTPException( + status_code=503, + detail=( + "No scanner detected on this server — check that the printer " + "is powered on and the USB cable is seated." + ), + ) + + +@router.post("/scan", response_model=ScanAccepted, status_code=201) +def start_scan_job(_: None = Depends(require_pin)): + """Start a flatbed scan at the driver's defaults (user-facing options + arrive in Phase 4). + + Accepts immediately — the transfer takes tens of seconds (spike S2 + measured 41 s at 200 dpi) — and hands the job to the background + pipeline. Poll GET /scan/jobs/{id} until it carries a download link. + """ + _gate() + job_id = uuid.uuid4().hex + job = scan_jobs.create_job(job_id) + logger.info("scan job %s accepted", job_id) + start_scan(job_id) + return ScanAccepted(job_id=job.job_id, status=job.status) + + +@router.get("/scan/jobs/{job_id}", response_model=ScanJob) +def scan_job_status(job_id: str): + """Poll a scan — the phone's "is my scan done yet?" endpoint. The + response carries download_url once the scan is done.""" + job = _get_job_or_404(job_id) + if job.status == ScanStatus.DONE: + job.download_url = f"/scan/jobs/{job_id}/download" + return job + + +@router.get("/scan/jobs/{job_id}/download") +def download_scan(job_id: str): + """The finished scan. 409 while it isn't done — the phone should poll + the status endpoint, whose done state carries this link.""" + job = _get_job_or_404(job_id) + if job.status != ScanStatus.DONE: + raise HTTPException( + status_code=409, + detail=f"Scan is '{job.status}' — there is nothing to download " + "until it's done.", + ) + path = downloads.result_path(job_id) + if not path.is_file(): + raise HTTPException( + status_code=404, + detail="The scanned file is gone — it may have been swept. Scan again.", + ) + return FileResponse(path, filename=job.filename) + + +@router.delete("/scan/jobs/{job_id}", response_model=ScanJob) +def cancel_scan(job_id: str, _: None = Depends(require_pin)): + """Cancel a queued/scanning scan and clean up whatever landed. + + A transfer already in flight cannot be interrupted mid-COM-call — the + pipeline notices the cancellation after the transfer and discards the + result (SCAN_PLAN §5). This endpoint removes whatever is already on + disk; the pipeline does the same if it notices first. + """ + _get_job_or_404(job_id) + ok, message = scan_jobs.cancel_job(job_id) + if not ok: + raise HTTPException(status_code=409, detail=message) + downloads.delete_job_files(job_id) + logger.info("scan job %s cancelled", job_id) + return scan_jobs.get_job(job_id) + + +def _get_job_or_404(job_id: str) -> ScanJob: + job = scan_jobs.get_job(job_id) + if job is None: + raise HTTPException( + status_code=404, detail=f"No scan job with id '{job_id}'." + ) + return job diff --git a/app/config.py b/app/config.py index 6f530ff..61f7ef8 100644 --- a/app/config.py +++ b/app/config.py @@ -37,6 +37,10 @@ def _get(name: str, default: str) -> str: # Where uploaded PDFs are stored temporarily (SOURCE_OF_TRUTH Section 10) UPLOAD_DIR = BASE_DIR / "uploads" +# Where finished scans wait for the phone to download them (SCAN_PLAN §5). +# Same hygiene model as uploads/: server-generated names, startup sweep. +DOWNLOAD_DIR = BASE_DIR / "downloads" + # Section 8: cap upload size so a huge/malicious file can't hurt us MAX_UPLOAD_MB = int(_get("MAX_UPLOAD_MB", "25")) diff --git a/app/main.py b/app/main.py index 2de0ea1..c8b2f1d 100644 --- a/app/main.py +++ b/app/main.py @@ -33,9 +33,11 @@ from app.api.jobs import router as jobs_router from app.api.print import router as print_router from app.api.printers import router as printers_router +from app.api.scan import router as scan_router from app.api.scanners import router as scanners_router from app.api.web import router as web_router -from app.services import jobs +from app.services import jobs, scan_jobs +from app.services.downloads import sweep_stale_downloads from app.services.logging_setup import setup_logging from app.services.uploads import sweep_stale_uploads @@ -56,12 +58,21 @@ async def lifespan(app: FastAPI): removed = sweep_stale_uploads() if removed: print(f"[startup] swept {removed} stale upload(s) from a previous run") + swept = sweep_stale_downloads() + if swept: + print(f"[startup] swept {swept} stale download(s) from a previous run") recovered = jobs.recover_interrupted() if recovered: print( f"[startup] marked {recovered} interrupted job(s) as failed " "(service restarted mid-print)" ) + scans = scan_jobs.recover_interrupted() + if scans: + print( + f"[startup] marked {scans} interrupted scan(s) as failed " + "(service restarted mid-scan)" + ) yield # Shutdown: nothing to clean yet. @@ -97,3 +108,7 @@ def health(): # reports available=false, and nothing else in the app changes. app.include_router(scanners_router) +# Scan pipeline (docs/SCAN_PLAN.md Phase 2): POST /scan + job status / +# download / cancel. Own store, own namespace — never touches print code. +app.include_router(scan_router) + diff --git a/app/models/scanning.py b/app/models/scanning.py index 82ea266..b3eb589 100644 --- a/app/models/scanning.py +++ b/app/models/scanning.py @@ -5,6 +5,8 @@ the scan feature must never reach into print code (SCAN_PLAN §4). """ +from datetime import datetime + from pydantic import BaseModel @@ -25,3 +27,42 @@ class ScannersInfo(BaseModel): available: bool devices: list[ScanDevice] + + +class ScanStatus: + """The scan lifecycle (SCAN_PLAN §5) — deliberately shorter than + print's: no conversion step, WIA either hands back an image or not. + + queued → scanning → done + ↘ failed + queued or scanning → cancelled + """ + + QUEUED = "queued" + SCANNING = "scanning" + DONE = "done" + FAILED = "failed" + CANCELLED = "cancelled" + + +class ScanAccepted(BaseModel): + """Response for POST /scan (SCAN_PLAN §4): accepted immediately — the + transfer runs in a background thread. Poll GET /scan/jobs/{id}.""" + + job_id: str + status: str + + +class ScanJob(BaseModel): + """One tracked scan job — the scan store's own shape. Deliberately no + print columns (printer, options, category): a scan is not a print job + in either direction (SCAN_PLAN §4).""" + + job_id: str + filename: str # the download name the phone sees (server-generated) + size_bytes: int = 0 + status: str + created_at: datetime + updated_at: datetime + error: str | None = None + download_url: str | None = None # set by the API once done diff --git a/app/scanner/windows.py b/app/scanner/windows.py index babd246..7975737 100644 --- a/app/scanner/windows.py +++ b/app/scanner/windows.py @@ -21,6 +21,7 @@ """ import logging +from pathlib import Path from app.config import ENABLE_SCAN from app.models.scanning import ScanDevice @@ -87,3 +88,117 @@ def scanning_supported() -> bool: tests patch it HERE on this module, not on app.config. """ return ENABLE_SCAN and scan_available() + + +# --------------------------------------------------------------------------- +# The scan half (SCAN_PLAN §5): one flatbed page out — or a readable error. +# +# Unlike the detection functions above, these MAY raise: RuntimeError with +# a phone-user-readable message, exactly like submit_pdf's contract with +# the print pipeline. The scan pipeline records it as the job's error. +# --------------------------------------------------------------------------- + +# WIA's PNG format ID, passed as a raw GUID (SCAN_PLAN §0: avoid +# win32com.client.constants — it needs a makepy-generated module). +WIA_FORMAT_PNG = "{B96B3CAB-0728-11D3-9D7B-0000F81EF32E}" + +# WIA error HRESULTs (mapped from the low 32 bits) → what the phone user +# can actually do. Unmapped codes fall back to the raw error text. Same +# spirit as the print engine's SumatraPDF exit-code catalog (p15). +WIA_ERROR_MESSAGES = { + 0x80210001: "The scanner reported a paper jam. Clear it and try again.", + 0x80210002: ( + "No document was detected on the scanner glass. Place the page " + "face down and try again." + ), + 0x80210004: ( + "The scanner is offline — check that the printer is powered on and " + "the USB cable is seated." + ), + 0x80210005: "The scanner is busy. Wait for the current job and try again.", + 0x80210007: ( + "The scanner needs attention — check that the cover is closed and " + "look at the error light." + ), + 0x80210009: ( + "The scanner stopped responding. Re-seat the USB cable and try again." + ), + 0x8021000C: ( + "The scanner is locked by another application. Close it and try again." + ), +} + + +def _human_scan_error(exc: Exception) -> str: + """Translate a WIA COM error into something a phone user can act on. + + pywin32's com_error buries the HRESULT in args[2][5]; WIA's specific + codes live in 0x802100xx. + """ + args = getattr(exc, "args", ()) + scode = None + if len(args) >= 3 and isinstance(args[2], tuple) and len(args[2]) >= 6: + scode = args[2][5] + if isinstance(scode, int) and scode < 0: + mapped = WIA_ERROR_MESSAGES.get(scode & 0xFFFFFFFF) + if mapped: + return mapped + return f"The scan failed: {exc}" + + +def _item_label(item) -> str: + """An item's friendly name, trying both WIA property names.""" + for prop in ("Item Name", "Name"): + try: + return str(item.Properties(prop).Value) + except Exception: + continue + return "" + + +def _open_flatbed_item(): + """Connect to the first WIA scanner and return a transferable item. + + Prefers an item whose name mentions "flat" (matters on multi-item + devices with a feeder); on the L3210 there is exactly one item and it + IS the flatbed (proven by spike S2). + """ + import win32com.client + + manager = win32com.client.Dispatch("WIA.DeviceManager") + infos = manager.DeviceInfos + for index in range(1, infos.Count + 1): + info = infos.Item(index) + if info.Type != WIA_SCANNER_TYPE: + continue + device = info.Connect() + items = device.Items + order = sorted( + range(1, items.Count + 1), + key=lambda i: "flat" not in _item_label(items.Item(i)).lower(), + ) + return items.Item(order[0]) + raise RuntimeError( + "The scanner was not found — check the USB connection and try again." + ) + + +def scan_flatbed(dest: Path) -> Path: + """Transfer one flatbed page to a PNG at `dest` (SCAN_PLAN §5 step 3). + + Driver-default resolution and color — the user-facing options (dpi, + color_mode, format) arrive in Phase 4. The PNG lands ONLY if the + transfer succeeded: WIA's SaveFile refuses to overwrite (spike S4's + 0x80070050 lesson), so the caller must pass a fresh server-generated + name — which every caller here does. + """ + try: + + item = _open_flatbed_item() + image = item.Transfer(WIA_FORMAT_PNG) + image.SaveFile(str(dest)) + except RuntimeError: + raise # already human-readable ("scanner was not found", ...) + except Exception as exc: + raise RuntimeError(_human_scan_error(exc)) from exc + return dest diff --git a/app/services/downloads.py b/app/services/downloads.py new file mode 100644 index 0000000..46d1dde --- /dev/null +++ b/app/services/downloads.py @@ -0,0 +1,76 @@ +"""downloads/ — where finished scans wait for the phone (SCAN_PLAN §5/§7). + +Mirror of uploads.py's hygiene rules, scan side: + + - every filename in here is SERVER-generated (the job id) — the client + never names scan files, which kills path traversal by construction; + - a finished scan is kept until the phone grabs it (unlike a print, the + file IS the deliverable — nothing "prints" it away), so the startup + sweep is the cleanup safety net for crashed runs; + - dotfiles (e.g. .gitkeep) survive the sweep, exactly like uploads/. +""" + +import logging +from pathlib import Path + +from app.config import DOWNLOAD_DIR + +logger = logging.getLogger(__name__) + + +def ensure_downloads_dir() -> None: + DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True) + + +def result_path(job_id: str) -> Path: + """The finished scan's location: downloads/.pdf. + + Phase 2's one output format (the print side's one internal format, by + the same logic); the format= escape hatch arrives in Phase 4. + """ + return DOWNLOAD_DIR / f"{job_id}.pdf" + + +def working_path(job_id: str) -> Path: + """The WIA transfer's raw PNG — wrapped into the PDF by the pipeline + and deleted on success, kept on failure for diagnosing.""" + return DOWNLOAD_DIR / f"{job_id}.png" + + +def job_files(job_id: str) -> list[Path]: + """Every file belonging to a scan job (raw PNG + finished PDF). + Defined once so the pipeline's cleanup and the cancel endpoint agree + on what a scan leaves behind.""" + ensure_downloads_dir() + return sorted(p for p in DOWNLOAD_DIR.glob(f"{job_id}.*") if p.is_file()) + + +def delete_job_files(job_id: str) -> int: + """Delete every file of a scan job. Returns how many were removed.""" + removed = 0 + for path in job_files(job_id): + try: + path.unlink() + removed += 1 + except OSError: + pass # never let cleanup crash the service + return removed + + +def sweep_stale_downloads() -> int: + """Startup safety net: a previous run that died left scan files with + nobody to download them. Same rule as the uploads sweep — dotfiles + are kept, everything else goes.""" + ensure_downloads_dir() + removed = 0 + for stale in DOWNLOAD_DIR.iterdir(): + if not stale.is_file() or stale.name.startswith("."): + continue # directories (or oddities) and dotfiles are skipped + try: + stale.unlink() + removed += 1 + except OSError: + pass # never let cleanup crash the service + if removed: + logger.info("swept %d stale download(s) from a previous run", removed) + return removed diff --git a/app/services/scan_jobs.py b/app/services/scan_jobs.py new file mode 100644 index 0000000..497d780 --- /dev/null +++ b/app/services/scan_jobs.py @@ -0,0 +1,186 @@ +"""Scan job store (docs/SCAN_PLAN.md §4/§5). + +Deliberately NOT the print store (SCAN_PLAN §4): print's states and +columns ("printing", printer, options, category) don't fit a scan, and +the scan feature must never reach into print code. So: a separate +`scan_jobs` table in the SAME SQLite file (config: JOB_DB_PATH), owned by +this module with ITS OWN connection and ITS OWN RLock — app/services/ +jobs.py's shared connection is never touched, which keeps the "scan never +modifies print code" guarantee literal. + +Lifecycle (SCAN_PLAN §5, deliberately shorter than print's — there is no +conversion step; WIA either hands back an image or it doesn't): + + queued → scanning → done + ↘ failed + queued or scanning → cancelled + +Startup recovery (recover_interrupted, called from main's lifespan, after +the downloads sweep) flips scans left queued/scanning by a crashed run to +failed — their files are gone either way, so there is nothing to deliver. +""" + +import sqlite3 +import threading +from datetime import datetime, timezone +from pathlib import Path + +from app.config import JOB_DB_PATH +from app.models.scanning import ScanJob, ScanStatus + +# States a cancel may interrupt; done/failed/cancelled are terminal. +CANCELLABLE = frozenset({ScanStatus.QUEUED, ScanStatus.SCANNING}) + +_lock = threading.RLock() +_db_path = Path(JOB_DB_PATH) +_conn: sqlite3.Connection | None = None + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS scan_jobs ( + job_id TEXT PRIMARY KEY, + filename TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + error TEXT +) +""" + + +def _get_conn() -> sqlite3.Connection: + """The shared connection, created (with schema) on first use. + + Own connection, own lock (SCAN_PLAN §0 adjustment 4): a sqlite3 + connection must not be used from two threads at once, and each + store's lock protects only its own — sharing jobs.py's would couple + the two subsystems this feature is built to keep apart. + """ + global _conn + if _conn is None: + _db_path.parent.mkdir(parents=True, exist_ok=True) + _conn = sqlite3.connect(_db_path, check_same_thread=False) + _conn.row_factory = sqlite3.Row + _conn.execute(_SCHEMA) + _conn.commit() + return _conn + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _to_job(row: sqlite3.Row) -> ScanJob: + return ScanJob( + job_id=row["job_id"], + filename=row["filename"], + size_bytes=row["size_bytes"], + status=row["status"], + created_at=datetime.fromisoformat(row["created_at"]), + updated_at=datetime.fromisoformat(row["updated_at"]), + error=row["error"], + ) + + +def create_job(job_id: str) -> ScanJob: + """Register a freshly accepted scan: queued, file not yet on disk. + + The filename is the download name the phone will see — server + generated like everything else in downloads/ (SCAN_PLAN §7). + """ + filename = f"scan-{job_id[:8]}.pdf" + now = _now() + with _lock: + _get_conn().execute( + "INSERT INTO scan_jobs (job_id, filename, size_bytes, status," + " created_at, updated_at) VALUES (?, ?, 0, ?, ?, ?)", + (job_id, filename, ScanStatus.QUEUED, now, now), + ) + _get_conn().commit() + row = _get_conn().execute( + "SELECT * FROM scan_jobs WHERE job_id = ?", (job_id,) + ).fetchone() + return _to_job(row) + + +def get_job(job_id: str) -> ScanJob | None: + with _lock: + row = _get_conn().execute( + "SELECT * FROM scan_jobs WHERE job_id = ?", (job_id,) + ).fetchone() + return _to_job(row) if row is not None else None + + +def update_status( + job_id: str, + status: str, + error: str | None = None, + size_bytes: int | None = None, +) -> None: + """Move a scan along its lifecycle (used by the scan pipeline). + + error/size_bytes are only written when provided; reaching 'done' + clears a stale error, since the scan obviously succeeded. Unknown ids + are a silent no-op — the caller runs on a background thread and must + never raise. + """ + with _lock: + _get_conn().execute( + "UPDATE scan_jobs SET status = ?, updated_at = ?," + " error = CASE WHEN ? = 'done' THEN NULL ELSE COALESCE(?, error) END," + " size_bytes = COALESCE(?, size_bytes)" + " WHERE job_id = ?", + (status, _now(), status, error, size_bytes, job_id), + ) + _get_conn().commit() + + +def cancel_job(job_id: str) -> tuple[bool, str]: + """Cancel a scan that hasn't reached a terminal state. + + Returns (ok, message). A transfer in flight cannot be interrupted + mid-COM-call — the pipeline re-checks the status after the transfer + and discards the result (the same between-stages rule the print + pipeline has followed since p14). + """ + with _lock: + row = _get_conn().execute( + "SELECT status FROM scan_jobs WHERE job_id = ?", (job_id,) + ).fetchone() + if row is None: + return False, "No such scan job." + status = row["status"] + if status not in CANCELLABLE: + return False, ( + f"Scan is '{status}' — only scans that haven't finished can " + "be cancelled." + ) + _get_conn().execute( + "UPDATE scan_jobs SET status = 'cancelled', updated_at = ?" + " WHERE job_id = ?", + (_now(), job_id), + ) + _get_conn().commit() + return True, "Cancelled." + + +def recover_interrupted() -> int: + """Mark scans left queued/scanning by a previous run as failed. + + Called once at startup, after the downloads sweep — their files are + gone either way, and the error says what happened. Returns how many + scans were recovered. + """ + with _lock: + cursor = _get_conn().execute( + "UPDATE scan_jobs SET status = 'failed', updated_at = ?, error = ?" + " WHERE status IN (?, ?)", + ( + _now(), + "Service restarted before this scan finished.", + ScanStatus.QUEUED, + ScanStatus.SCANNING, + ), + ) + _get_conn().commit() + return cursor.rowcount diff --git a/app/services/scan_pipeline.py b/app/services/scan_pipeline.py new file mode 100644 index 0000000..b02ee9e --- /dev/null +++ b/app/services/scan_pipeline.py @@ -0,0 +1,106 @@ +"""Scan pipeline (docs/SCAN_PLAN.md §5, Phase 2) — turns an accepted scan +job into a PDF in downloads/. + +Why a background thread: a flatbed transfer takes tens of seconds (spike +S2 measured 41.4 s at 200 dpi — 56.1 s while a print ran). Doing it inside +the HTTP request would make the phone wait with no feedback; the response +returns immediately with status "queued", and the job's status moves +forward in the store: + + queued → scanning → done + ↘ failed + +The pipeline's shape (SCAN_PLAN §5 step 3 — reused, not reinvented): + + WIA transfer (raw PNG, driver defaults) → + REAL ImageProcessor — the print side's fit-to-page code, the exact + reuse spike S3 proved on hardware → + downloads/.pdf → raw PNG deleted + +Between every stage the job's status is re-checked: a cancel always wins +over the next step, and a cancelled scan is never marked done (the same +rule the print pipeline has followed since p14). +""" + +import logging +import threading + +from app.models.scanning import ScanStatus +from app.processors.images import IMAGE_PROCESSOR +from app.scanner.windows import scan_flatbed +from app.services import downloads, scan_jobs + +logger = logging.getLogger(__name__) + + +def start_scan(job_id: str) -> None: + """Hand a freshly accepted scan job to a background scan thread.""" + current = scan_jobs.get_job(job_id) + if current is not None and current.status == ScanStatus.CANCELLED: + # The cancel raced in between accept and this call — scanning + # would resurrect it (update_status doesn't know better). + logger.info("scan %s cancelled before it started — not scanning", job_id) + downloads.delete_job_files(job_id) + return + scan_jobs.update_status(job_id, ScanStatus.QUEUED) + threading.Thread( + target=_process, + args=(job_id,), + name=f"scan-{job_id[:8]}", + daemon=True, # never block service shutdown on a stuck scan + ).start() + + +def _cancelled(job_id: str) -> bool: + """Whether the user cancelled — checked between stages so a cancel + always wins over the next step.""" + job = scan_jobs.get_job(job_id) + return job is not None and job.status == ScanStatus.CANCELLED + + +def _process(job_id: str) -> None: + png_path = downloads.working_path(job_id) + pdf_path = downloads.result_path(job_id) + try: + downloads.ensure_downloads_dir() # fresh installs have no downloads/ + scan_jobs.update_status(job_id, ScanStatus.SCANNING) + if _cancelled(job_id): + _abandon(job_id, "before the transfer") + return + + scan_flatbed(png_path) + if _cancelled(job_id): + _abandon(job_id, "after the transfer") + return + + # The REAL production path for image → print-ready PDF: the same + # fit/center/white-flatten code the print side uses for photo + # uploads. process() names its output .pdf — exactly + # result_path(job_id). + pdf_path = IMAGE_PROCESSOR.process(png_path, downloads.DOWNLOAD_DIR) + if _cancelled(job_id): + _abandon(job_id, "after the wrap") + return + + size = pdf_path.stat().st_size + try: + png_path.unlink() # the deliverable is the PDF; drop the raw PNG + except OSError: + pass # never let cleanup fail the job + + scan_jobs.update_status(job_id, ScanStatus.DONE, size_bytes=size) + logger.info("scan %s done (%d bytes)", job_id, size) + + except Exception as exc: + logger.exception("scan %s failed", job_id) + # Keep whatever landed on disk — a raw PNG diagnoses WIA trouble. + # The startup sweep is the eventual cleanup; there is no retry in + # Phase 2 (the phone just scans again). + scan_jobs.update_status(job_id, ScanStatus.FAILED, error=str(exc)) + + +def _abandon(job_id: str, where: str) -> None: + """Clean up after a cancellation noticed at a stage boundary — a + cancelled scan's files are nobody's deliverable.""" + downloads.delete_job_files(job_id) + logger.info("scan %s cancelled %s — nothing delivered", job_id, where) diff --git a/docs/SCAN_PLAN.md b/docs/SCAN_PLAN.md index 689c14c..eb38119 100644 --- a/docs/SCAN_PLAN.md +++ b/docs/SCAN_PLAN.md @@ -2,9 +2,9 @@ Status: **approved plan, compatibility-reviewed (§0); Phase 0 COMPLETE — S1/S2/S3/S4 all PASS on the real L3210 (2026-09-01), including the -unplugged clean-degradation proof. Phase 1 (detection) LANDED — -`GET /scanners` live, print code untouched. Next: Phase 2 (scan -pipeline). Branch `scan-feature`.** +unplugged clean-degradation proof. Phase 1 (detection) and Phase 2 +(basic scan pipeline: POST /scan + status/download/cancel + downloads/) +LANDED. Next: Phase 3 (web UI). Branch `scan-feature`.** Goal: add an optional **scan** capability (Android → Python service → Windows → USB → printer's scanner glass → back to phone) to the existing print service, **without ever affecting printing** on a printer that has @@ -353,6 +353,32 @@ ruff clean. **Print code untouched** — `app/printer/`, `pipeline.py`, `POST /scan` (flatbed, default resolution/color only, PDF output), job lifecycle + `downloads/`, `/scan/jobs/{id}` + download endpoint. +**Landed (2026-09-01):** `app/scanner/windows.py` grew the scan half — +`scan_flatbed(dest)` (driver-default resolution/color, PNG transfer; the +WIA half that raises does so with phone-readable messages: a HRESULT map +for the common WIA errors — busy/offline/jam/cover — with raw-text +fallback, same spirit as the print side's exit-code catalog) and +`_open_flatbed_item()` (flatbed-preferring item selection, spike-proven). +`app/services/scan_jobs.py`: the separate `scan_jobs` table (same SQLite +file, own connection + own RLock — jobs.py's connection never touched; +`create/get/update_status/cancel_job/recover_interrupted`). Lifecycle: +`queued → scanning → done | failed | cancelled`, cancel checked between +every pipeline stage, a cancelled scan never marked done. +`app/services/scan_pipeline.py`: background daemon thread — WIA transfer → +**real ImageProcessor** (the print side's fit-to-page code, spike-S3 +reuse) → `downloads/.pdf`, raw PNG deleted on success / kept on +failure. `app/services/downloads.py`: `downloads/` hygiene (server- +generated names, dotfiles survive, startup sweep). `app/api/scan.py`: +`POST /scan` (201 + job id, PIN-gated, 503 with an actionable message +when disabled/scanner-less), `GET /scan/jobs/{id}` (carries +`download_url` when done), `GET /scan/jobs/{id}/download` +(FileResponse), `DELETE /scan/jobs/{id}` (cancel + cleanup, PIN-gated). +`main.py`: downloads sweep + scan recovery in the lifespan, one additive +router mount. Tests: 14 scan-store/downloads unit tests, 9 pipeline tests +(fakes with in-flight gates: COM-error translation, vanished scanner, +corrupt-image wrap failure, cancel-mid-transfer discard), 15 API tests. +Suite: 323 tests, 96.0 % coverage, ruff clean. Print code untouched. + ### Phase 3 — web UI polish Scan button, status polling, download/view link — reusing the existing @@ -427,5 +453,5 @@ Same CI gates apply: `ruff check .` + `pytest --cov-fail-under=90`. approved. Phase 0's spike is CLOSED: S1 (plugged + unplugged), S2, S3 and S4 all PASS on the real L3210 — the scan feature is proven feasible with zero new dependencies, and a scanner-less setup is proven safe. Phase 1 -(detection) has landed. Phase 2 (the basic scan pipeline) is the next -slice to build.* \ No newline at end of file +(detection) and Phase 2 (basic scan pipeline) have landed. Phase 3 (web +UI: scan button, polling, download link) is the next slice to build.* \ No newline at end of file diff --git a/tests/api/test_scan_api.py b/tests/api/test_scan_api.py new file mode 100644 index 0000000..9eefab1 --- /dev/null +++ b/tests/api/test_scan_api.py @@ -0,0 +1,131 @@ +"""API tests for the scan surface (docs/SCAN_PLAN.md §4/§9). + +POST /scan mirrors POST /print's contract (201 + job id, work in a +background thread); refusals are 503 with an actionable message (never a +500); PIN applies to state-changing routes only. +""" + +from app.services import scan_jobs + + +class TestPostScan: + def test_accepts_and_queues_with_a_fake_scanner(self, client, fake_win32com): + fake_win32com.add_device() + response = client.post("/scan") + assert response.status_code == 201 + body = response.json() + assert set(body) == {"job_id", "status"} # SCAN_PLAN §4 shape + assert body["status"] == "queued" + assert scan_jobs.get_job(body["job_id"]) is not None + + def test_scanner_less_setup_is_a_503_not_a_500(self, client, fake_win32com): + response = client.post("/scan") + assert response.status_code == 503 + assert "No scanner" in response.json()["detail"] + + def test_wia_failure_at_accept_time_is_a_503(self, client, fake_win32com): + fake_win32com.add_device() + fake_win32com.fail_dispatch(RuntimeError("WIA service disabled")) + response = client.post("/scan") + assert response.status_code == 503 + + def test_kill_switch_disables_with_a_clear_message( + self, client, fake_win32com, monkeypatch + ): + fake_win32com.add_device() # hardware present, feature off + monkeypatch.setattr("app.api.scan.ENABLE_SCAN", False) + response = client.post("/scan") + assert response.status_code == 503 + assert "ENABLE_SCAN=0" in response.json()["detail"] + + def test_requires_the_pin_when_one_is_configured( + self, client, fake_win32com, monkeypatch + ): + fake_win32com.add_device() + monkeypatch.setattr("app.services.auth.API_PIN", "1234") + assert client.post("/scan").status_code == 401 + ok = client.post("/scan", headers={"X-API-PIN": "1234"}) + assert ok.status_code == 201 + + +class TestScanStatus: + def test_unknown_job_is_404(self, client): + assert client.get("/scan/jobs/ghost").status_code == 404 + + def test_done_scan_carries_the_download_link( + self, client, fake_win32com, wait_for_scan_status + ): + fake_win32com.add_device() + job_id = client.post("/scan").json()["job_id"] + wait_for_scan_status(job_id, "done") + + body = client.get(f"/scan/jobs/{job_id}").json() + assert body["status"] == "done" + assert body["download_url"] == f"/scan/jobs/{job_id}/download" + assert body["size_bytes"] > 0 + assert body["filename"].startswith("scan-") + + def test_incomplete_scan_has_no_download_link( + self, client, fake_win32com, monkeypatch + ): + fake_win32com.add_device() + monkeypatch.setattr("app.api.scan.start_scan", lambda job_id: None) + job_id = client.post("/scan").json()["job_id"] + + body = client.get(f"/scan/jobs/{job_id}").json() + assert body["status"] == "queued" + assert body["download_url"] is None + + +class TestScanDownload: + def test_download_before_done_is_409(self, client, fake_win32com, monkeypatch): + fake_win32com.add_device() + monkeypatch.setattr("app.api.scan.start_scan", lambda job_id: None) + job_id = client.post("/scan").json()["job_id"] + + response = client.get(f"/scan/jobs/{job_id}/download") + assert response.status_code == 409 + assert "queued" in response.json()["detail"] + + def test_download_delivers_the_finished_pdf( + self, client, fake_win32com, wait_for_scan_status + ): + fake_win32com.add_device() + job_id = client.post("/scan").json()["job_id"] + wait_for_scan_status(job_id, "done") + + response = client.get(f"/scan/jobs/{job_id}/download") + assert response.status_code == 200 + assert response.content[:5] == b"%PDF-" + assert "attachment" in response.headers["content-disposition"] + + def test_download_unknown_job_is_404(self, client): + assert client.get("/scan/jobs/ghost/download").status_code == 404 + + +class TestScanCancel: + def test_cancel_a_queued_scan(self, client, fake_win32com, monkeypatch): + fake_win32com.add_device() + monkeypatch.setattr("app.api.scan.start_scan", lambda job_id: None) + job_id = client.post("/scan").json()["job_id"] + + response = client.delete(f"/scan/jobs/{job_id}") + assert response.status_code == 200 + assert response.json()["status"] == "cancelled" + # Cancelling again refuses: the scan is already terminal. + assert client.delete(f"/scan/jobs/{job_id}").status_code == 409 + + def test_cancel_unknown_job_is_404(self, client): + assert client.delete("/scan/jobs/ghost").status_code == 404 + + def test_cancel_requires_the_pin( + self, client, fake_win32com, monkeypatch + ): + fake_win32com.add_device() + monkeypatch.setattr("app.api.scan.start_scan", lambda job_id: None) + job_id = client.post("/scan").json()["job_id"] + monkeypatch.setattr("app.services.auth.API_PIN", "1234") + + assert client.delete(f"/scan/jobs/{job_id}").status_code == 401 + ok = client.delete(f"/scan/jobs/{job_id}", headers={"X-API-PIN": "1234"}) + assert ok.status_code == 200 diff --git a/tests/conftest.py b/tests/conftest.py index 395d61e..f4b8cce 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,7 +30,7 @@ from fastapi.testclient import TestClient from app.printer import windows -from app.services import jobs +from app.services import jobs, scan_jobs # The printer name the fakes pretend Windows reports (matching the real # L3210's queue name keeps the tests readable). @@ -52,6 +52,10 @@ def fresh_job_store(tmp_path, monkeypatch): """ monkeypatch.setattr(jobs, "_db_path", tmp_path / "jobs.sqlite3") monkeypatch.setattr(jobs, "_conn", None) + # The scan store shares the DB FILE but owns its own connection — + # patch both halves (SCAN_PLAN §0: scan never touches print's store). + monkeypatch.setattr(scan_jobs, "_db_path", tmp_path / "jobs.sqlite3") + monkeypatch.setattr(scan_jobs, "_conn", None) @pytest.fixture(autouse=True) @@ -67,6 +71,18 @@ def tmp_upload_dir(tmp_path, monkeypatch) -> Path: return upload_dir +@pytest.fixture(autouse=True) +def tmp_download_dir(tmp_path, monkeypatch) -> Path: + """Redirect downloads/ to a temp dir for every test — scan artifacts + (raw PNG, finished PDF) and the startup sweep, same rule as + tmp_upload_dir (SCAN_PLAN §5: downloads/ is uploads/' twin).""" + download_dir = tmp_path / "downloads" + # Tests write into it directly — the dir must exist up front. + download_dir.mkdir() + monkeypatch.setattr("app.services.downloads.DOWNLOAD_DIR", download_dir) + return download_dir + + # --------------------------------------------------------------------------- # HTTP client # --------------------------------------------------------------------------- @@ -188,19 +204,69 @@ def add_device( wia_type: int = 1, device_id=None, no_name: bool = False, + transfer_error: Exception | None = None, + entered: threading.Event | None = None, + gate: threading.Event | None = None, + corrupt_png: bool = False, ): - """Add one WIA DeviceInfo. no_name=True simulates a device whose - Properties("Name") read fails (the _display_name fallback path).""" + """Add one WIA DeviceInfo. + + no_name=True simulates a device whose Properties("Name") read + fails (the _display_name fallback path). + transfer_error: item.Transfer() raises it (WIA trouble mid-scan). + entered/gate: a SLOW scanner — Transfer() sets entered, then waits + on gate before "saving" — letting a test cancel mid-transfer. + corrupt_png: SaveFile writes garbage bytes, so the REAL + ImageProcessor refuses the image (wrap-failure path). + """ def properties(prop_name): if no_name: raise RuntimeError(f"no {prop_name} property") return types.SimpleNamespace(Value=name) + def transfer(_format_id): + if entered is not None: + entered.set() + if transfer_error is not None: + raise transfer_error + if gate is not None: + gate.wait(timeout=10) + + def save_file(path): + if corrupt_png: + # SaveFile hands a str path (the production call passes + # str(dest)) — write garbage the ImageProcessor refuses. + Path(path).write_bytes(b"not an image at all") + return + # A tiny REAL PNG — the pipeline wraps it with the real + # ImageProcessor, which needs genuine image bytes. + from PIL import Image + + Image.new("RGB", (16, 12), "white").save(path, "PNG") + + return types.SimpleNamespace(SaveFile=save_file) + + # The transferable flatbed item, reachable exactly the way the + # production _open_flatbed_item() walks WIA: + # DeviceInfos -> info.Connect() -> device.Items -> Item(1). + item = types.SimpleNamespace(Transfer=transfer, Properties=properties) + items = types.SimpleNamespace( + Count=1, + Item=lambda index: item + if index == 1 + else (_ for _ in ()).throw(IndexError(index)), + ) + + def connect(): + return types.SimpleNamespace(Items=items) + info = types.SimpleNamespace( Type=wia_type, DeviceID=device_id or f"wia-device-{next(sequence)}", Properties=properties, + Transfer=transfer, + Connect=connect, ) infos.items.append(info) return info @@ -299,6 +365,29 @@ def _wait(job_id: str, *statuses: str, timeout: float = 5.0): return _wait +@pytest.fixture +def wait_for_scan_status(): + """Poll the SCAN store until a scan reaches one of the statuses — + the twin of wait_for_status for the separate scan store (SCAN_PLAN + §4: scan jobs live in their own table, so they get their own waiter).""" + + def _wait(job_id: str, *statuses: str, timeout: float = 5.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + job = scan_jobs.get_job(job_id) + if job is not None and job.status in statuses: + return job + time.sleep(0.01) + job = scan_jobs.get_job(job_id) + last = job.status if job is not None else "" + raise AssertionError( + f"scan {job_id!r} never reached {statuses} within {timeout}s " + f"(last state: {last})" + ) + + return _wait + + # --------------------------------------------------------------------------- # Test data # --------------------------------------------------------------------------- diff --git a/tests/unit/test_downloads.py b/tests/unit/test_downloads.py new file mode 100644 index 0000000..0cf6531 --- /dev/null +++ b/tests/unit/test_downloads.py @@ -0,0 +1,47 @@ +"""Unit tests for app/services/downloads.py (docs/SCAN_PLAN.md §5/§7). + +downloads/ is uploads/' twin: server-generated names, dotfiles survive +the sweep, everything else is startup-swept. +""" + +from app.services import downloads + + +class TestPaths: + def test_result_and_working_paths_are_server_generated(self): + assert downloads.result_path("j1").name == "j1.pdf" + assert downloads.working_path("j1").name == "j1.png" + + +class TestJobFiles: + def test_job_files_and_delete(self, tmp_download_dir): + downloads.working_path("j1").write_bytes(b"png") + downloads.result_path("j1").write_bytes(b"pdf") + assert len(downloads.job_files("j1")) == 2 + assert downloads.delete_job_files("j1") == 2 + assert downloads.job_files("j1") == [] + + def test_delete_ignores_foreign_files(self, tmp_download_dir): + (tmp_download_dir / "other.pdf").write_bytes(b"x") + assert downloads.delete_job_files("j1") == 0 + assert (tmp_download_dir / "other.pdf").exists() + + +class TestSweep: + def test_sweep_removes_files_but_keeps_dotfiles(self, tmp_download_dir): + (tmp_download_dir / "abc.pdf").write_bytes(b"x") + (tmp_download_dir / "def.png").write_bytes(b"y") + (tmp_download_dir / ".gitkeep").write_text("") + + removed = downloads.sweep_stale_downloads() + + assert removed == 2 + assert (tmp_download_dir / ".gitkeep").exists() + assert not (tmp_download_dir / "abc.pdf").exists() + + def test_sweep_on_a_missing_directory_creates_it(self, tmp_download_dir, monkeypatch): + import shutil + + shutil.rmtree(tmp_download_dir) + assert downloads.sweep_stale_downloads() == 0 + assert tmp_download_dir.is_dir() diff --git a/tests/unit/test_scan_jobs.py b/tests/unit/test_scan_jobs.py new file mode 100644 index 0000000..7c4d37e --- /dev/null +++ b/tests/unit/test_scan_jobs.py @@ -0,0 +1,90 @@ +"""Unit tests for the scan job store (docs/SCAN_PLAN.md §5). + +The store is deliberately separate from the print store (SCAN_PLAN §4) — +these tests also verify the two never share a connection: fresh_job_store +patches both, and every operation here only ever touches scan_jobs. +""" + +from app.services import scan_jobs + + +class TestCreateAndGet: + def test_create_starts_queued_with_a_download_name(self): + job = scan_jobs.create_job("abcdef1234567890") + assert job.status == "queued" + assert job.size_bytes == 0 + assert job.filename == "scan-abcdef12.pdf" # server-generated (§7) + assert scan_jobs.get_job("abcdef1234567890").status == "queued" + + def test_unknown_job_is_none(self): + assert scan_jobs.get_job("nope") is None + + +class TestUpdateStatus: + def test_moves_through_the_lifecycle(self): + scan_jobs.create_job("job1") + scan_jobs.update_status("job1", "scanning") + assert scan_jobs.get_job("job1").status == "scanning" + scan_jobs.update_status("job1", "done", size_bytes=1234) + job = scan_jobs.get_job("job1") + assert job.status == "done" + assert job.size_bytes == 1234 + assert job.error is None + + def test_error_is_recorded_and_cleared_by_done(self): + scan_jobs.create_job("job2") + scan_jobs.update_status("job2", "failed", error="glass empty") + assert scan_jobs.get_job("job2").error == "glass empty" + scan_jobs.update_status("job2", "done") + assert scan_jobs.get_job("job2").error is None + + def test_existing_error_sticks_when_none_provided(self): + scan_jobs.create_job("job3") + scan_jobs.update_status("job3", "failed", error="busy") + scan_jobs.update_status("job3", "failed") # no new error given + assert scan_jobs.get_job("job3").error == "busy" + + def test_unknown_id_is_a_silent_noop(self): + # Background threads call this — it must never raise. + scan_jobs.update_status("ghost", "done") + + +class TestCancel: + def test_queued_and_scanning_are_cancellable(self): + scan_jobs.create_job("c1") + ok, message = scan_jobs.cancel_job("c1") + assert ok + assert scan_jobs.get_job("c1").status == "cancelled" + + scan_jobs.create_job("c2") + scan_jobs.update_status("c2", "scanning") + ok, _ = scan_jobs.cancel_job("c2") + assert ok + + def test_terminal_states_refuse(self): + scan_jobs.create_job("c3") + scan_jobs.update_status("c3", "done") + ok, message = scan_jobs.cancel_job("c3") + assert not ok + assert "'done'" in message + assert scan_jobs.get_job("c3").status == "done" + + def test_unknown_job_refuses(self): + ok, message = scan_jobs.cancel_job("ghost") + assert not ok + assert "No such" in message + + +class TestRecovery: + def test_active_scans_fail_on_startup(self): + scan_jobs.create_job("r1") + scan_jobs.create_job("r2") + scan_jobs.update_status("r2", "scanning") + scan_jobs.create_job("r3") + scan_jobs.update_status("r3", "done") # finished scans survive + + assert scan_jobs.recover_interrupted() == 2 + assert scan_jobs.get_job("r1").status == "failed" + assert "restarted" in scan_jobs.get_job("r1").error + assert scan_jobs.get_job("r2").status == "failed" + assert scan_jobs.get_job("r3").status == "done" diff --git a/tests/unit/test_scan_pipeline.py b/tests/unit/test_scan_pipeline.py new file mode 100644 index 0000000..5c77132 --- /dev/null +++ b/tests/unit/test_scan_pipeline.py @@ -0,0 +1,153 @@ +"""Scan pipeline tests (docs/SCAN_PLAN.md §9): the WIA boundary is faked, +the ImageProcessor and the store are REAL — no scanner is touched. Same +bounded-polling style as the print pipeline tests.""" + +import threading + +from app.models.scanning import ScanStatus +from app.services import downloads, scan_jobs +from app.services.scan_pipeline import start_scan + + +def _com_error(scode: int) -> Exception: + """A pywin32-shaped COM error: args[2][5] carries the HRESULT (exactly + the shape spike S4's SaveFile collision produced). + + Deliberately NOT a RuntimeError: pywintypes.com_error isn't one either, + and scan_flatbed only re-raises RuntimeErrors as already-translated + (its own "scanner was not found" message).""" + class FakeComError(Exception): + pass + + exc = FakeComError() + exc.args = ( + -2147352567, + "Exception occurred.", + (0, "WIA.Device.1", "device error", None, 0, scode), + None, + ) + return exc + + +class TestHappyPath: + def test_scan_job_completes_with_a_real_pdf( + self, fake_win32com, wait_for_scan_status, tmp_download_dir + ): + fake_win32com.add_device() + scan_jobs.create_job("scanok") + start_scan("scanok") + + job = wait_for_scan_status("scanok", ScanStatus.DONE) + pdf = downloads.result_path("scanok") + assert pdf.is_file() + assert pdf.read_bytes()[:5] == b"%PDF-" # wrapped by ImageProcessor + assert job.size_bytes > 0 + assert job.filename == "scan-scanok.pdf" + # The raw PNG is gone; only the deliverable remains (§5 step 3). + assert not downloads.working_path("scanok").exists() + assert [p.name for p in tmp_download_dir.iterdir()] == [pdf.name] + + def test_scanning_status_is_visible_while_the_thread_runs( + self, fake_win32com, wait_for_scan_status + ): + entered = threading.Event() + gate = threading.Event() + fake_win32com.add_device(entered=entered, gate=gate) + scan_jobs.create_job("slowscan") + start_scan("slowscan") + + assert entered.wait(timeout=5) # transfer in flight + job = wait_for_scan_status("slowscan", ScanStatus.SCANNING) + assert job.status == "scanning" + gate.set() + wait_for_scan_status("slowscan", ScanStatus.DONE) + + +class TestFailures: + def test_com_error_is_translated_to_a_human_message( + self, fake_win32com, wait_for_scan_status + ): + # WIA_ERROR_BUSY (0x80210005), signed 32-bit like pywin32 reports. + fake_win32com.add_device(transfer_error=_com_error(0x80210005 - 2**32)) + scan_jobs.create_job("busy1") + start_scan("busy1") + + job = wait_for_scan_status("busy1", ScanStatus.FAILED) + assert "busy" in job.error.lower() + + def test_plain_error_falls_back_to_its_text( + self, fake_win32com, wait_for_scan_status + ): + fake_win32com.add_device(transfer_error=RuntimeError("glass empty")) + scan_jobs.create_job("plain1") + start_scan("plain1") + + job = wait_for_scan_status("plain1", ScanStatus.FAILED) + assert "glass empty" in job.error + + def test_vanished_scanner_fails_with_a_readable_error( + self, fake_win32com, wait_for_scan_status + ): + # Detection said "yes" at accept time, but by transfer time WIA + # sees no scanner at all (USB yanked) — a FAILED job, not a 500. + scan_jobs.create_job("gone1") + start_scan("gone1") + + job = wait_for_scan_status("gone1", ScanStatus.FAILED) + assert "not found" in job.error + + def test_failed_scan_keeps_its_raw_png_for_diagnosis( + self, fake_win32com, wait_for_scan_status + ): + # The PNG lands but is garbage, so the REAL ImageProcessor refuses + # it — the job fails and the raw PNG stays (startup sweep cleans up + # eventually; there is no retry in Phase 2, the phone scans again). + fake_win32com.add_device(corrupt_png=True) + + scan_jobs.create_job("keep1") + start_scan("keep1") + wait_for_scan_status("keep1", ScanStatus.FAILED) + assert downloads.working_path("keep1").is_file() + assert not downloads.result_path("keep1").exists() + + +class TestCancellation: + def test_cancelled_before_start_never_scans(self, fake_win32com): + entered = threading.Event() + fake_win32com.add_device(entered=entered) + scan_jobs.create_job("early1") + scan_jobs.cancel_job("early1") + + start_scan("early1") # must not resurrect the cancelled job + + assert scan_jobs.get_job("early1").status == ScanStatus.CANCELLED + assert not entered.is_set() # no transfer was ever attempted + + def test_cancel_during_transfer_discards_the_result( + self, fake_win32com, wait_for_scan_status, wait_until + ): + entered = threading.Event() + gate = threading.Event() + fake_win32com.add_device(entered=entered, gate=gate) + scan_jobs.create_job("midcan") + start_scan("midcan") + + assert entered.wait(timeout=5) # transfer in flight + ok, _ = scan_jobs.cancel_job("midcan") + assert ok + gate.set() # the scanner finishes — but the job is already cancelled + + job = wait_for_scan_status("midcan", ScanStatus.CANCELLED) + wait_until( + lambda: downloads.job_files("midcan") == [], + message="cancel cleanup never removed the scan files", + ) + # A cancelled scan is never marked done, even though the image arrived. + assert job.status == ScanStatus.CANCELLED + + +class TestStartScanGuards: + def test_start_scan_is_safe_for_unknown_ids(self): + # update_status is a no-op for unknown ids; the thread dies quietly. + start_scan("ghost") + assert scan_jobs.get_job("ghost") is None From 82c2bbf76ed055f25a59e391159fe9b458e0b721 Mon Sep 17 00:00:00 2001 From: geb Date: Wed, 2 Sep 2026 11:35:50 +0800 Subject: [PATCH 5/7] fix: per-thread COM apartment for WIA - CoInitialize/CoUninitialize + proxies die before teardown Live smoke-check (2026-09-02) caught two real-hardware bugs: 1. CO_E_NOTINITIALIZED (-2147221008) on /scan: COM apartments are per-thread; WIA ran on uvicorn's threadpool + the scan background thread, neither of which had called CoInitialize (the spike ran on the main thread). This had also been silently disabling /scanners on the running server. Fix: _com_apartment() context manager around every WIA call - pythoncom.CoInitialize/CoUninitialize balanced, no-op without pywin32 so CI fakes behave identically. 2. Segfault + 'Win32 exception releasing IUnknown' after the first fix: using COM proxies after their thread's CoUninitialize is undefined behavior. Fix: the whole WIA session now lives in a helper (_detect_scanners_via_com / _transfer_flatbed_via_com) whose frame - and every COM local - dies INSIDE the apartment, so only plain ScanDevice data / paths leave it. Verified live through a real uvicorn: /scanners now returns available:true with EPSON L3210 Series; thread-context enumeration is warning-free. Regression test pins init/uninit balance on a background thread. Ruff clean; 324 passed, 95.8% coverage. Print code untouched. --- app/scanner/windows.py | 109 ++++++++++++++++++++++++++----------- docs/SCAN_PLAN.md | 24 ++++++-- tests/unit/test_scanner.py | 23 ++++++++ 3 files changed, 118 insertions(+), 38 deletions(-) diff --git a/app/scanner/windows.py b/app/scanner/windows.py index 7975737..8b5c24d 100644 --- a/app/scanner/windows.py +++ b/app/scanner/windows.py @@ -21,6 +21,7 @@ """ import logging +from contextlib import contextmanager from pathlib import Path from app.config import ENABLE_SCAN @@ -32,21 +33,62 @@ WIA_SCANNER_TYPE = 1 -def list_scan_devices() -> list[ScanDevice]: - """Ask Windows which scanners exist right now. NEVER raises.""" +@contextmanager +def _com_apartment(): + """COM apartments are per-THREAD: every thread that touches WIA must + call CoInitialize first, or COM raises CO_E_NOTINITIALIZED + (-2147221008 — caught live by the Phase 2 smile-check). + + The scan endpoints run on uvicorn's thread-pool threads and the scan + pipeline on its own background thread — neither is the main thread, + where importing pywin32 happened to initialize COM. The spike never + saw this because it called WIA from the main thread. + + Balanced Initialize/Uninitialize around the WIA work. On machines + without pywin32 (the CI runner) there is no COM at all — yield + unchanged, so the faked detection/scan tests behave identically. + """ try: - import win32com.client + import pythoncom + except ImportError: + yield + return + pythoncom.CoInitialize() + try: + yield + finally: + pythoncom.CoUninitialize() + - manager = win32com.client.Dispatch("WIA.DeviceManager") - infos = manager.DeviceInfos - count = infos.Count +def list_scan_devices() -> list[ScanDevice]: + """Ask Windows which scanners exist right now. NEVER raises. + + COM apartments are per-thread AND a COM proxy must not outlive its + thread's apartment — _detect_scanners_via_com() does the whole session + and returns plain data, so its frame (and every COM local) is + destroyed BEFORE the _com_apartment() block exits and uninitializes + the thread. Both mistakes were caught live in the Phase 2 + smile-check: skipping CoInitialize gave CO_E_NOTINITIALIZED, and + letting proxies outlive CoUninitialize segfaulted. + """ + try: + with _com_apartment(): + return _detect_scanners_via_com() except Exception as exc: # Missing pywin32, WIA service disabled, COM blow-up: all mean the # same thing to this feature — "no scanner on this machine". logger.warning("WIA scanner detection unavailable: %s", exc) return [] + +def _detect_scanners_via_com() -> list[ScanDevice]: + """The WIA enumeration session (call inside _com_apartment).""" + import win32com.client + devices: list[ScanDevice] = [] + manager = win32com.client.Dispatch("WIA.DeviceManager") + infos = manager.DeviceInfos + count = infos.Count for index in range(1, count + 1): # WIA collections are 1-based try: info = infos.Item(index) @@ -156,13 +198,32 @@ def _item_label(item) -> str: return "" -def _open_flatbed_item(): - """Connect to the first WIA scanner and return a transferable item. +def scan_flatbed(dest: Path) -> Path: + """Transfer one flatbed page to a PNG at `dest` (SCAN_PLAN §5 step 3). - Prefers an item whose name mentions "flat" (matters on multi-item - devices with a feeder); on the L3210 there is exactly one item and it - IS the flatbed (proven by spike S2). + Driver-default resolution and color — the user-facing options (dpi, + color_mode, format) arrive in Phase 4. The PNG lands ONLY if the + transfer succeeded: WIA's SaveFile refuses to overwrite (spike S4's + 0x80070050 lesson), so the caller must pass a fresh server-generated + name — which every caller here does. + + May raise RuntimeError with a phone-readable message (the scan + pipeline records it as the job's error); the _com_apartment wrapper + keeps every COM proxy inside the session, so nothing outlives the + thread's CoUninitialize (see list_scan_devices). """ + try: + with _com_apartment(): + _transfer_flatbed_via_com(dest) + except RuntimeError: + raise # already human-readable ("scanner was not found", ...) + except Exception as exc: + raise RuntimeError(_human_scan_error(exc)) from exc + return dest + + +def _transfer_flatbed_via_com(dest: Path) -> None: + """One flatbed WIA transfer session (call inside _com_apartment).""" import win32com.client manager = win32com.client.Dispatch("WIA.DeviceManager") @@ -177,28 +238,10 @@ def _open_flatbed_item(): range(1, items.Count + 1), key=lambda i: "flat" not in _item_label(items.Item(i)).lower(), ) - return items.Item(order[0]) + item = items.Item(order[0]) + image = item.Transfer(WIA_FORMAT_PNG) + image.SaveFile(str(dest)) + return raise RuntimeError( "The scanner was not found — check the USB connection and try again." ) - - -def scan_flatbed(dest: Path) -> Path: - """Transfer one flatbed page to a PNG at `dest` (SCAN_PLAN §5 step 3). - - Driver-default resolution and color — the user-facing options (dpi, - color_mode, format) arrive in Phase 4. The PNG lands ONLY if the - transfer succeeded: WIA's SaveFile refuses to overwrite (spike S4's - 0x80070050 lesson), so the caller must pass a fresh server-generated - name — which every caller here does. - """ - try: - - item = _open_flatbed_item() - image = item.Transfer(WIA_FORMAT_PNG) - image.SaveFile(str(dest)) - except RuntimeError: - raise # already human-readable ("scanner was not found", ...) - except Exception as exc: - raise RuntimeError(_human_scan_error(exc)) from exc - return dest diff --git a/docs/SCAN_PLAN.md b/docs/SCAN_PLAN.md index eb38119..76f2218 100644 --- a/docs/SCAN_PLAN.md +++ b/docs/SCAN_PLAN.md @@ -1,10 +1,10 @@ # Scan Feature — Feasibility, Decision Record & Roadmap Status: **approved plan, compatibility-reviewed (§0); Phase 0 COMPLETE — -S1/S2/S3/S4 all PASS on the real L3210 (2026-09-01), including the -unplugged clean-degradation proof. Phase 1 (detection) and Phase 2 -(basic scan pipeline: POST /scan + status/download/cancel + downloads/) -LANDED. Next: Phase 3 (web UI). Branch `scan-feature`.** +S1/S2/S3/S4 all PASS on the real L3210 (2026-09-01). Phases 1–2 LANDED, +plus the per-thread COM fix (2026-09-02) caught by the live smoke-check +(CoInitialize on background threads; proxies die before CoUninitialize). +Next: Phase 3 (web UI). Branch `scan-feature`.** Goal: add an optional **scan** capability (Android → Python service → Windows → USB → printer's scanner glass → back to phone) to the existing print service, **without ever affecting printing** on a printer that has @@ -377,7 +377,21 @@ when disabled/scanner-less), `GET /scan/jobs/{id}` (carries router mount. Tests: 14 scan-store/downloads unit tests, 9 pipeline tests (fakes with in-flight gates: COM-error translation, vanished scanner, corrupt-image wrap failure, cancel-mid-transfer discard), 15 API tests. -Suite: 323 tests, 96.0 % coverage, ruff clean. Print code untouched. +Suite: 324 tests (incl. the per-thread COM regression test), 95.8 % +coverage, ruff clean. Print code untouched. + +**Fixed post-smile-check (2026-09-02):** WIA ran on the app's +*background* threads (uvicorn's pool + the scan thread), and COM +apartments are per-thread. Live `/scan` failed with +`CO_E_NOTINITIALIZED` (and `/scanners` was silently reporting +scanner-less). Fix: `_com_apartment()` — `pythoncom.CoInitialize` / +`CoUninitialize` around every WIA call (no-op without pywin32, so CI +unchanged), and a second live-caught bug while fixing that: COM proxies +must not outlive the thread's `CoUninitialize` (segfault + IUnknown +release exceptions) → the whole WIA session now lives in a helper whose +frame dies inside the apartment, so only plain data escapes. Verified +live: `/scanners` through a real uvicorn reports the L3210, and +thread-context enumeration is warning-free. ### Phase 3 — web UI polish diff --git a/tests/unit/test_scanner.py b/tests/unit/test_scanner.py index dd610b5..5cfa937 100644 --- a/tests/unit/test_scanner.py +++ b/tests/unit/test_scanner.py @@ -7,6 +7,7 @@ """ import sys +import types from app.scanner import windows as scanner_windows @@ -87,3 +88,25 @@ def test_supported_needs_hardware_even_when_enabled( ): monkeypatch.setattr(scanner_windows, "ENABLE_SCAN", True) assert scanner_windows.scanning_supported() is False + + +class TestComApartment: + def test_com_is_initialized_on_the_calling_thread( + self, fake_win32com, monkeypatch + ): + # Regression for the live smile-check failure: WIA runs on uvicorn + # threadpool / background threads, which need their own + # CoInitialize — the main thread's apartment doesn't help them. + calls = [] + monkeypatch.setitem( + sys.modules, + "pythoncom", + types.SimpleNamespace( + CoInitialize=lambda: calls.append("init"), + CoUninitialize=lambda: calls.append("uninit"), + ), + ) + fake_win32com.add_device() + + assert scanner_windows.list_scan_devices() # the fake WIA ran fine + assert calls == ["init", "uninit"] # initialized AND balanced From 7d72bf3a4aedc4e8baba27086f20d15a32da4056 Mon Sep 17 00:00:00 2001 From: geb Date: Wed, 2 Sep 2026 11:46:11 +0800 Subject: [PATCH 6/7] scan-p3: web UI - real Scan button, status polling, View/Download link app/api/web.py: the placeholder Scan section is now real - an enabled Scan button (scanBtn/startScan) that POSTs /scan with the same PIN-header handling as the Print button, then polls GET /scan/jobs/{id} on print's 2s cadence (pollScan, ~2.5min cap covering the spike's 40-60s transfers). On done it renders a View/Download link built from the server-issued job id (nothing from the server enters innerHTML); failed/cancelled show the server's message. The button stays disabled while a scan is in flight - a flatbed only does one page at a time (a second tap would just hit WIA_ERROR_BUSY). Section still display:none by default - scanner-less setups see the identical print-only page (SCAN_PLAN 1). tests: test_health_web.py grew TestScanWebUi asserting the Scan UI ships (button, onclick, fetch(/scan), /scan/jobs/, pollScan) and the section stays display:none / print-first. Suite: 326 passed, 96.0% coverage, ruff clean. Live-verified through uvicorn: /scanners available:true, page carries id="scanBtn" onclick="startScan()" and the pollScan JS. Print code untouched. --- app/api/web.py | 114 ++++++++++++++++++++++++++++++++--- docs/SCAN_PLAN.md | 27 +++++++-- tests/api/test_health_web.py | 23 +++++++ 3 files changed, 149 insertions(+), 15 deletions(-) diff --git a/app/api/web.py b/app/api/web.py index 6818636..c573b73 100644 --- a/app/api/web.py +++ b/app/api/web.py @@ -135,16 +135,16 @@ - + @@ -294,9 +294,9 @@ } } -// Scan feature (docs/SCAN_PLAN.md §6): ask the server ONCE whether this -// printer setup can scan at all. No scanner (or the feature disabled) → -// the section never renders and the page stays the familiar print-only +// Scan feature (docs/SCAN_PLAN.md §6/Phase 3): ask the server ONCE whether +// this printer setup can scan at all. No scanner (or the feature disabled) +// → the section never renders and the page stays the familiar print-only // one. Detection failure must never break the page, hence the catch. (async function checkScanner() { try { @@ -311,6 +311,102 @@ // Stay print-only (SCAN_PLAN §3: detection is never load-bearing). } })(); + +// The Scan button: POST /scan, then poll the job the same way print does. +// The button stays disabled while a scan is in flight — the flatbed can +// only do one page at a time, so a second tap would just hit a busy +// scanner (WIA_ERROR_BUSY) instead of queueing usefully. +const scanBtn = document.getElementById("scanBtn"); +const scanResult = document.getElementById("scanResult"); +let scanInFlight = false; + +function scanShow(text, cls) { + scanResult.textContent = text; + scanResult.className = cls || ""; +} + +async function startScan() { + if (scanInFlight) { return; } + scanInFlight = true; + scanBtn.disabled = true; + scanShow("📨 Starting scan…", "ok"); + const pin = document.getElementById("pin").value.trim(); + const headers = pin ? { "X-API-PIN": pin } : {}; + try { + const response = await fetch("/scan", { method: "POST", headers }); + const data = await response.json(); + if (response.ok) { + scanShow("📨 Scan queued — the flatbed is working. Checking status…", "ok"); + pollScan(data.job_id, 0); + } else if (response.status === 401) { + scanInFlight = false; + scanBtn.disabled = false; + scanShow("❌ Wrong PIN.", "err"); + } else { + scanInFlight = false; + scanBtn.disabled = false; + scanShow("❌ Server said: " + (data.detail || response.status), "err"); + } + } catch (networkError) { + scanInFlight = false; + scanBtn.disabled = false; + scanShow("❌ Could not reach the server. Are you on the same Wi-Fi?", "err"); + } +} + +// Poll a scan job until it's done — mirrors print's poll() (SCAN_PLAN §6 +// is explicit: reuse the existing polling pattern, don't invent a new one). +async function pollScan(jobId, attempt) { + if (attempt > 75) { // ~2.5 min; scans take 40-60 s (spike S2) + print load + scanInFlight = false; + scanBtn.disabled = false; + scanShow("⏳ Still not confirmed after ~2.5 min. Check /scan/jobs/" + jobId + + " for the current status.", "ok"); + return; + } + try { + const pin = document.getElementById("pin").value.trim(); + const headers = pin ? { "X-API-PIN": pin } : {}; + const response = await fetch("/scan/jobs/" + jobId, { headers }); + if (!response.ok) { + scanInFlight = false; + scanBtn.disabled = false; + scanShow("❌ Lost track of scan job " + jobId + " (HTTP " + + response.status + ")", "err"); + return; + } + const job = await response.json(); + if (job.status === "done") { + scanInFlight = false; + scanBtn.disabled = false; + // The link is built from the server-issued job id (a UUID hex) — + // nothing from the server goes into innerHTML, and the download + // endpoint is the only thing it ever points at. + scanResult.className = "ok"; + scanResult.innerHTML = "✅ Scan ready — " + + 'View / Download' + + " (job " + jobId + ")"; + return; + } + if (job.status === "failed") { + scanInFlight = false; + scanBtn.disabled = false; + scanShow("❌ Scan failed: " + (job.error || "unknown reason"), "err"); + return; + } + if (job.status === "cancelled") { + scanInFlight = false; + scanBtn.disabled = false; + scanShow("Scan job " + jobId + " was cancelled.", "err"); + return; + } + scanShow("⏳ status: " + job.status, "ok"); + setTimeout(() => pollScan(jobId, attempt + 1), 2000); + } catch (networkError) { + // One dropped poll shouldn't end monitoring — keep trying. + setTimeout(() => pollScan(jobId, attempt + 1), 3000); + } +} """ diff --git a/docs/SCAN_PLAN.md b/docs/SCAN_PLAN.md index 76f2218..eaffd77 100644 --- a/docs/SCAN_PLAN.md +++ b/docs/SCAN_PLAN.md @@ -2,9 +2,10 @@ Status: **approved plan, compatibility-reviewed (§0); Phase 0 COMPLETE — S1/S2/S3/S4 all PASS on the real L3210 (2026-09-01). Phases 1–2 LANDED, -plus the per-thread COM fix (2026-09-02) caught by the live smoke-check -(CoInitialize on background threads; proxies die before CoUninitialize). -Next: Phase 3 (web UI). Branch `scan-feature`.** +plus the per-thread COM fix (2026-09-02) caught by the live smoke-check. +Phase 3 (web UI: real Scan button + polling + download link) LANDED — +the scan feature is fully usable from the phone. Next: Phase 4 (scan +options). Branch `scan-feature`.** Goal: add an optional **scan** capability (Android → Python service → Windows → USB → printer's scanner glass → back to phone) to the existing print service, **without ever affecting printing** on a printer that has @@ -398,6 +399,20 @@ thread-context enumeration is warning-free. Scan button, status polling, download/view link — reusing the existing page's polling pattern rather than inventing a new one. +**Landed (2026-09-02):** the page's placeholder Scan section became real — +`app/api/web.py` now ships an enabled **Scan** button (`id="scanBtn"`, +`startScan()`) that POSTs `/scan` with the same PIN-header handling as the +Print button, then polls `GET /scan/jobs/{id}` on print's 2 s cadence +(`pollScan`, attempt cap ~2.5 min to cover the spike's 40–60 s transfers +plus print load). On `done` it renders a **View / Download** link built +from the server-issued job id (`/scan/jobs//download` — nothing from +the server enters `innerHTML`); `failed`/`cancelled` show the server's +message and re-enable the button. The button stays disabled while a scan +is in flight — a flatbed can only do one page at a time, and a second tap +would just hit `WIA_ERROR_BUSY` instead of queueing usefully. Web-page +tests assert the Scan UI ships and the section is `display:none` by +default (SCAN_PLAN §1 answer 5 — printing stays first). + ### Phase 4 — scan options `dpi`, `color_mode`, `format=png|jpeg` escape hatch, all strictly @@ -466,6 +481,6 @@ Same CI gates apply: `ruff check .` + `pytest --cov-fail-under=90`. *This document was compatibility-reviewed against the code (§0) and approved. Phase 0's spike is CLOSED: S1 (plugged + unplugged), S2, S3 and S4 all PASS on the real L3210 — the scan feature is proven feasible with -zero new dependencies, and a scanner-less setup is proven safe. Phase 1 -(detection) and Phase 2 (basic scan pipeline) have landed. Phase 3 (web -UI: scan button, polling, download link) is the next slice to build.* \ No newline at end of file +zero new dependencies, and a scanner-less setup is proven safe. Phases 1 +(detection), 2 (basic scan pipeline) and 3 (web UI) have landed. Phase 4 +(scan options: dpi, color_mode, format) is the next slice to build.* \ No newline at end of file diff --git a/tests/api/test_health_web.py b/tests/api/test_health_web.py index e750df1..b63e08e 100644 --- a/tests/api/test_health_web.py +++ b/tests/api/test_health_web.py @@ -45,3 +45,26 @@ def test_favicon_ico_served(self, client): assert "image/svg+xml" in response.headers["content-type"] assert "