Skip to content

Scan feature - #2

Merged
SamAnaniasCases merged 7 commits into
mainfrom
scan-feature
Sep 2, 2026
Merged

SamAnaniasCases merged 7 commits into
mainfrom
scan-feature

Conversation

@SamAnaniasCases

@SamAnaniasCases SamAnaniasCases commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Description

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • Other (describe below)

Related issue

How was this tested?

Screenshots (if applicable)

Checklist

  • My code builds and runs locally
  • I've tested the change
  • I've updated docs/comments where needed

- 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%).
… 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.
…nal 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.
…/, 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/<job_id>.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.
… 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/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.
…mat-aware output

app/models/scanning.py: ScanOptions + validate_scan_options (422 on violation, mirrors validate_print_options); allowlists SCAN_DPI_CHOICES=(150,200,300) sized against spike timings, SCAN_COLOR_MODES, SCAN_FORMATS; SCAN_FILE_EXT; ScanJob.format.

app/scanner/windows.py: scan_flatbed(dest, dpi, color_mode) applies options to the WIA item best-effort - resolution via Horizontal/Vertical Resolution, color via Current Intent (WIA_IPS_CUR_INTENT 1=color/2=greyscale) with a Bits-Per-Pixel fallback; a refusing driver keeps its default.

app/services/scan_pipeline.py: deliverable by format - pdf (real ImageProcessor wrap), png (raw PNG IS the deliverable), jpeg (Pillow encode); raw PNG dropped except when it IS the output. app/services/scan_jobs.py: format column + ALTER migration (like the print store's options column); create_job(job_id, options) names the file scan-<id>.<ext>. app/services/downloads.py: result_path(job_id, ext).

app/api/scan.py: POST /scan accepts optional dpi/color_mode/format form fields, validates, passes options through. app/api/web.py: Scan options details (Resolution/Color/Format selects) in the print-options style; startScan posts them as FormData.

tests: scan-options validation unit tests; pipeline png/jpeg output + option-passthrough (fake WIA item records it was asked for 300dpi greyscale); API options + 422s + png/jpeg downloads; web controls. Suite: 342 passed, 95.7% coverage, ruff clean. Live-verified: /scanners true, bad dpi -> 422, page ships the controls.
@SamAnaniasCases
SamAnaniasCases merged commit 0075008 into main Sep 2, 2026
1 check passed
@SamAnaniasCases
SamAnaniasCases deleted the scan-feature branch September 2, 2026 07:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant