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/scan.py b/app/api/scan.py new file mode 100644 index 0000000..5195ca9 --- /dev/null +++ b/app/api/scan.py @@ -0,0 +1,141 @@ +"""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, Form, HTTPException +from fastapi.responses import FileResponse + +from app.models.scanning import ( + DEFAULT_DPI, + SCAN_FILE_EXT, + ScanAccepted, + ScanJob, + ScanStatus, + validate_scan_options, +) +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( + dpi: int = Form(DEFAULT_DPI), + color_mode: str = Form("color"), + format: str = Form("pdf"), + _: None = Depends(require_pin), +): + """Start a flatbed scan (Phase 4: dpi / color_mode / format options). + + All options are optional with safe defaults and strictly allowlisted — + validated BEFORE anything touches WIA, the same rule the print side + applies to its command-line-bound options (Phase 7). 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() + try: + options = validate_scan_options(dpi, color_mode, format).model_dump() + except ValueError as exc: + logger.warning("rejected scan request: %s", exc) + raise HTTPException(status_code=422, detail=str(exc)) + + job_id = uuid.uuid4().hex + job = scan_jobs.create_job(job_id, options) + logger.info("scan job %s accepted (%s)", job_id, options) + start_scan(job_id, options) + 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, SCAN_FILE_EXT[job.format]) + 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/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..b535087 100644 --- a/app/api/web.py +++ b/app/api/web.py @@ -134,6 +134,43 @@ + + + """ diff --git a/app/config.py b/app/config.py index 452531c..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")) @@ -86,3 +90,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..c8b2f1d 100644 --- a/app/main.py +++ b/app/main.py @@ -33,8 +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 @@ -55,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. @@ -91,3 +103,12 @@ 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) + +# 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 new file mode 100644 index 0000000..14f72b9 --- /dev/null +++ b/app/models/scanning.py @@ -0,0 +1,123 @@ +"""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 datetime import datetime + +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] + + +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 + format: str = "pdf" # what the finished file is (pdf/png/jpeg) + download_url: str | None = None # set by the API once done + + +# --------------------------------------------------------------------------- +# Scan options (SCAN_PLAN §8 Phase 4) — strict allowlists, same spirit as +# the print side's Phase 7 options (MULTI_FORMAT_PLAN.md §10): validated +# BEFORE anything touches WIA, exactly like print options are validated +# before they touch a command line. +# --------------------------------------------------------------------------- + +# DPI choices sized against the spike's real timings (S2: 200 dpi ≈ 41 s +# solo, 56 s with a concurrent print; 300 will be slower, 150 faster). +# 200 is the spike-verified default. +SCAN_DPI_CHOICES = (150, 200, 300) +SCAN_COLOR_MODES = ("color", "greyscale") +SCAN_FORMATS = ("pdf", "png", "jpeg") +DEFAULT_DPI = 200 + +# The on-disk extension a finished scan gets; the phone's download name +# follows it (scan-.pdf/.png/.jpg). +SCAN_FILE_EXT = {"pdf": "pdf", "png": "png", "jpeg": "jpg"} + + +class ScanOptions(BaseModel): + """Validated scan options attached to a scan job.""" + + dpi: int = DEFAULT_DPI + color_mode: str = "color" + format: str = "pdf" + + +def validate_scan_options(dpi: int, color_mode: str, format: str) -> ScanOptions: + """Validate raw form input and return normalized ScanOptions. + + Raises ValueError with a phone-user-readable message — the API layer + maps that to HTTP 422, exactly like validate_print_options. + """ + try: + dpi = int(dpi) + except (TypeError, ValueError): + raise ValueError("DPI must be a number (150, 200 or 300).") + if dpi not in SCAN_DPI_CHOICES: + raise ValueError( + f"DPI must be one of: {', '.join(map(str, SCAN_DPI_CHOICES))}." + ) + + color_mode = (color_mode or "color").strip().lower() + if color_mode not in SCAN_COLOR_MODES: + raise ValueError("Color mode must be 'color' or 'greyscale'.") + + format = (format or "pdf").strip().lower() + if format not in SCAN_FORMATS: + raise ValueError("Format must be 'pdf', 'png' or 'jpeg'.") + + return ScanOptions(dpi=dpi, color_mode=color_mode, format=format) 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/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..f6b3998 --- /dev/null +++ b/app/scanner/windows.py @@ -0,0 +1,295 @@ +"""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 contextlib import contextmanager +from pathlib import Path + +from app.config import ENABLE_SCAN +from app.models.scanning import DEFAULT_DPI, ScanDevice + +logger = logging.getLogger(__name__) + +# WIA DeviceInfo.Type values (SCAN_PLAN §2): 1 = scanner, 2 = camera, 3 = video. +WIA_SCANNER_TYPE = 1 + + +@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 pythoncom + except ImportError: + yield + return + pythoncom.CoInitialize() + try: + yield + finally: + pythoncom.CoUninitialize() + + +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) + 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() + + +# --------------------------------------------------------------------------- +# 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 scan_flatbed( + dest: Path, dpi: int = DEFAULT_DPI, color_mode: str = "color" +) -> Path: + """Transfer one flatbed page to a PNG at `dest` (SCAN_PLAN §5 step 3). + + `dpi`/`color_mode` are the Phase 4 options, applied best-effort by the + WIA driver — a driver that refuses a value keeps its default (the + spike's 200-dpi request behaved exactly this way). 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, dpi, color_mode) + 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 _apply_scan_options(item, dpi: int, color_mode: str) -> None: + """Request resolution/color on a WIA item, best-effort (never raises). + + Resolution uses the standard "Horizontal/Vertical Resolution" + properties. Color uses WIA's "Current Intent" (WIA_IPS_CUR_INTENT, + with WIA_INTENT_IMAGE_TYPE_COLOR=1 / _GRAYSCALE=2), falling back to + "Bits Per Pixel" (24=RGB / 8=greyscale) for drivers that prefer it. + """ + _set_item_option(item, "Horizontal Resolution", dpi) + _set_item_option(item, "Vertical Resolution", dpi) + intent = WIA_CUR_INTENT_BY_MODE.get(color_mode, WIA_INTENT_COLOR) + if not _set_item_option( + item, "Current Intent", intent, prop_id=WIA_IPS_CUR_INTENT + ): + _set_item_option( + item, "Bits Per Pixel", WIA_BITS_BY_MODE.get(color_mode, 24) + ) + + +def _set_item_option(item, prop_name: str, value, prop_id=None) -> bool: + """Best-effort set of one WIA item property; never raises. Returns + whether the driver accepted the set (by name, then by numeric id).""" + for key in (prop_name, prop_id): + if key is None: + continue + try: + item.Properties(key).Value = value + return True + except Exception: + continue + return False + + +# WIA item option constants (Phase 4). +WIA_IPS_CUR_INTENT = 6146 # WIA_IPS_CUR_INTENT — the color-intent property +WIA_INTENT_COLOR = 1 # WIA_INTENT_IMAGE_TYPE_COLOR +WIA_INTENT_GRAYSCALE = 2 # WIA_INTENT_IMAGE_TYPE_GRAYSCALE +WIA_CUR_INTENT_BY_MODE = { + "color": WIA_INTENT_COLOR, + "greyscale": WIA_INTENT_GRAYSCALE, +} +WIA_BITS_BY_MODE = {"color": 24, "greyscale": 8} + + +def _transfer_flatbed_via_com(dest: Path, dpi: int, color_mode: str) -> None: + """One flatbed WIA transfer session (call inside _com_apartment).""" + 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(), + ) + item = items.Item(order[0]) + _apply_scan_options(item, dpi, color_mode) + 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." + ) diff --git a/app/services/downloads.py b/app/services/downloads.py new file mode 100644 index 0000000..06c7fd4 --- /dev/null +++ b/app/services/downloads.py @@ -0,0 +1,77 @@ +"""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, ext: str = "pdf") -> Path: + """The finished scan's location: downloads/.. + + `ext` is the on-disk extension of the chosen format (pdf/png/jpg). + Phase 2 shipped the one PDF format; Phase 4's format=png|jpeg escape + hatch uses this to name the deliverable. + """ + return DOWNLOAD_DIR / f"{job_id}.{ext}" + + +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..8a68746 --- /dev/null +++ b/app/services/scan_jobs.py @@ -0,0 +1,199 @@ +"""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 SCAN_FILE_EXT, 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, + format TEXT NOT NULL DEFAULT 'pdf' +) +""" + + +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) + try: + # Migrates databases created before Phase 4 (no format column). + # "duplicate column" means the migration already ran. + _conn.execute( + "ALTER TABLE scan_jobs ADD COLUMN format TEXT NOT NULL DEFAULT 'pdf'" + ) + except sqlite3.OperationalError: + pass + _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"], + format=row["format"], + ) + + +def create_job(job_id: str, options: dict | None = None) -> 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), and its + extension follows the requested output format (Phase 4). + """ + options = options or {} + format = options.get("format") or "pdf" + filename = f"scan-{job_id[:8]}.{SCAN_FILE_EXT[format]}" + now = _now() + with _lock: + _get_conn().execute( + "INSERT INTO scan_jobs (job_id, filename, size_bytes, status," + " created_at, updated_at, format) VALUES (?, ?, 0, ?, ?, ?, ?)", + (job_id, filename, ScanStatus.QUEUED, now, now, format), + ) + _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..f50fef3 --- /dev/null +++ b/app/services/scan_pipeline.py @@ -0,0 +1,126 @@ +"""Scan pipeline (docs/SCAN_PLAN.md §5, Phase 2; options in Phase 4) — +turns an accepted scan job into a downloadable file 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, requested dpi/color best-effort) → + deliverable by format: + pdf → REAL ImageProcessor (the print side's fit-to-page code, the + exact reuse spike S3 proved on hardware) → .pdf + png → the raw PNG IS the deliverable (.png) + jpeg → Pillow encodes a JPEG (.jpg) + raw PNG deleted (except png format, where it IS the deliverable) + +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 PIL import Image + +from app.models.scanning import DEFAULT_DPI, 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__) + +JPEG_QUALITY = 92 + + +def start_scan(job_id: str, options: dict | None = None) -> 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, options), + 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, options: dict | None = None) -> None: + options = options or {} + dpi = options.get("dpi", DEFAULT_DPI) + color_mode = options.get("color_mode", "color") + output_format = options.get("format", "pdf") + + png_path = downloads.working_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, dpi=dpi, color_mode=color_mode) + if _cancelled(job_id): + _abandon(job_id, "after the transfer") + return + + if output_format == "png": + # The raw PNG IS the deliverable — nothing more to build. + result_path = png_path + else: + if output_format == "jpeg": + result_path = downloads.result_path(job_id, "jpg") + with Image.open(png_path) as img: + img.convert("RGB").save( + result_path, "JPEG", quality=JPEG_QUALITY + ) + else: # pdf — the default; the real print-side fit-to-page path + # process() names its output .pdf == result_path(id). + result_path = IMAGE_PROCESSOR.process( + png_path, downloads.DOWNLOAD_DIR + ) + if _cancelled(job_id): + _abandon(job_id, "after the wrap") + return + try: + png_path.unlink() # the PNG was intermediate; drop it + except OSError: + pass # never let cleanup fail the job + + size = result_path.stat().st_size + 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/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/SCAN_PLAN.md b/docs/SCAN_PLAN.md new file mode 100644 index 0000000..f953759 --- /dev/null +++ b/docs/SCAN_PLAN.md @@ -0,0 +1,507 @@ +# 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). Phases 1–4 LANDED — +detection, basic scan pipeline, web UI, and scan options (dpi/color/ +format) — plus the per-thread COM fix (2026-09-02). The scan MVP is +feature-complete. Next: any v2 polish; 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.** + +**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), +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: 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 + +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 +validated — same spirit as the print side's Phase 7 options work. + +**Landed (2026-09-02):** `app/models/scanning.py` grew the strict +allowlists (`SCAN_DPI_CHOICES = (150, 200, 300)` sized against the +spike's real timings; `SCAN_COLOR_MODES`; `SCAN_FORMATS`) and +`validate_scan_options()` → 422 on any violation (mirrors +`validate_print_options`). `app/scanner/windows.py` applies the options +to the WIA item best-effort — resolution via the standard +Horizontal/Vertical Resolution properties, color via `Current Intent` +(WIA_IPS_CUR_INTENT: 1=color, 2=greyscale) with a Bits-Per-Pixel +fallback — a refusing driver keeps its default and the scan still runs. +`app/services/scan_pipeline.py` produces the deliverable by format: pdf +(the REAL ImageProcessor wrap), png (the raw PNG is the deliverable), or +jpeg (Pillow encode); the scan store gained a `format` column (migration +in `_get_conn`, like the print store's options column) so the download +endpoint and the phone's filename follow the chosen format +(`scan-.pdf/.png/.jpg`). `POST /scan` accepts the three optional +form fields; the web page's Scan options details offers the DPI/Color/ +Format selects in the print-options style. Tests: validation unit tests, +pipeline png/jpeg/option-passthrough (asserting the fake WIA item was +asked for 300 dpi greyscale), API options + 422s, web controls. Suite: +342 tests, 95.7 % coverage, ruff clean. Verified live through uvicorn: +bad dpi → 422, page ships the controls. + +### ⚪ 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. Phases 1 +(detection), 2 (basic scan pipeline), 3 (web UI) and 4 (scan options: +dpi, color_mode, format) have landed — the scan MVP is complete.* \ No newline at end of file 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/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()) diff --git a/tests/api/test_health_web.py b/tests/api/test_health_web.py index e750df1..1d57af3 100644 --- a/tests/api/test_health_web.py +++ b/tests/api/test_health_web.py @@ -45,3 +45,35 @@ def test_favicon_ico_served(self, client): assert "image/svg+xml" in response.headers["content-type"] assert "