Skip to content
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
141 changes: 141 additions & 0 deletions app/api/scan.py
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions app/api/scanners.py
Original file line number Diff line number Diff line change
@@ -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)
157 changes: 157 additions & 0 deletions app/api/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,43 @@
</select>
</label>
</details>

<!-- Scan section (docs/SCAN_PLAN.md §6/Phase 3): the JS below renders
this ONLY when GET /scanners reports a scanner. On a scanner-less
setup it stays hidden and the page is exactly the print-only one. -->
<div id="scanSection" style="display:none; margin-top:14px;">
<p class="sub" style="margin-bottom:8px;">
📇 Scanner detected: <span id="scanName"></span>
</p>
<button id="scanBtn" onclick="startScan()">Scan</button>
<details style="margin-top:10px;">
<summary style="cursor:pointer; font-size:.85rem; color:#667;">
Scan options
</summary>
<label class="opt">Resolution
<select id="scanDpi">
<option value="150">150 dpi (faster)</option>
<option value="200" selected>200 dpi</option>
<option value="300">300 dpi (slower)</option>
</select>
</label>
<label class="opt">Color
<select id="scanColorMode">
<option value="color" selected>Color</option>
<option value="greyscale">Greyscale</option>
</select>
</label>
<label class="opt">Format
<select id="scanFormat">
<option value="pdf" selected>PDF (document)</option>
<option value="png">PNG (image)</option>
<option value="jpeg">JPEG (image)</option>
</select>
</label>
</details>
<div id="scanResult" style="margin-top:10px; font-size:.92rem;
white-space:pre-wrap; word-break:break-word;"></div>
</div>
</div>

<script>
Expand Down Expand Up @@ -281,6 +318,126 @@
setTimeout(() => poll(jobId, attempt + 1), 3000);
}
}

// Scan feature (docs/SCAN_PLAN.md §6/Phase 3): ask the server ONCE whether
// this printer setup can scan at all. No scanner (or the feature disabled)
// → the section never renders and the page stays the familiar print-only
// one. Detection failure must never break the page, hence the catch.
(async function checkScanner() {
try {
const response = await fetch("/scanners");
if (!response.ok) { return; }
const info = await response.json();
if (!info.available || !info.devices.length) { return; }
document.getElementById("scanName").textContent =
info.devices[0].name || "scanner";
document.getElementById("scanSection").style.display = "block";
} catch (e) {
// Stay print-only (SCAN_PLAN §3: detection is never load-bearing).
}
})();

// The Scan button: POST /scan, then poll the job the same way print does.
// The button stays disabled while a scan is in flight — the flatbed can
// only do one page at a time, so a second tap would just hit a busy
// scanner (WIA_ERROR_BUSY) instead of queueing usefully.
const scanBtn = document.getElementById("scanBtn");
const scanResult = document.getElementById("scanResult");
let scanInFlight = false;

function scanShow(text, cls) {
scanResult.textContent = text;
scanResult.className = cls || "";
}

async function startScan() {
if (scanInFlight) { return; }
scanInFlight = true;
scanBtn.disabled = true;
scanShow("📨 Starting scan…", "ok");
const pin = document.getElementById("pin").value.trim();
const headers = pin ? { "X-API-PIN": pin } : {};
// Scan options (Phase 4): DPI, color mode, output format — strictly
// allowlisted server-side; the selects only ever offer valid values.
const body = new FormData();
body.append("dpi", document.getElementById("scanDpi").value);
body.append("color_mode", document.getElementById("scanColorMode").value);
body.append("format", document.getElementById("scanFormat").value);
try {
const response = await fetch("/scan", { method: "POST", headers, body });
const data = await response.json();
if (response.ok) {
scanShow("📨 Scan queued — the flatbed is working. Checking status…", "ok");
pollScan(data.job_id, 0);
} else if (response.status === 401) {
scanInFlight = false;
scanBtn.disabled = false;
scanShow("❌ Wrong PIN.", "err");
} else {
scanInFlight = false;
scanBtn.disabled = false;
scanShow("❌ Server said: " + (data.detail || response.status), "err");
}
} catch (networkError) {
scanInFlight = false;
scanBtn.disabled = false;
scanShow("❌ Could not reach the server. Are you on the same Wi-Fi?", "err");
}
}

// Poll a scan job until it's done — mirrors print's poll() (SCAN_PLAN §6
// is explicit: reuse the existing polling pattern, don't invent a new one).
async function pollScan(jobId, attempt) {
if (attempt > 75) { // ~2.5 min; scans take 40-60 s (spike S2) + print load
scanInFlight = false;
scanBtn.disabled = false;
scanShow("⏳ Still not confirmed after ~2.5 min. Check /scan/jobs/" + jobId +
" for the current status.", "ok");
return;
}
try {
const pin = document.getElementById("pin").value.trim();
const headers = pin ? { "X-API-PIN": pin } : {};
const response = await fetch("/scan/jobs/" + jobId, { headers });
if (!response.ok) {
scanInFlight = false;
scanBtn.disabled = false;
scanShow("❌ Lost track of scan job " + jobId + " (HTTP " +
response.status + ")", "err");
return;
}
const job = await response.json();
if (job.status === "done") {
scanInFlight = false;
scanBtn.disabled = false;
// The link is built from the server-issued job id (a UUID hex) —
// nothing from the server goes into innerHTML, and the download
// endpoint is the only thing it ever points at.
scanResult.className = "ok";
scanResult.innerHTML = "✅ Scan ready — " +
'<a href="/scan/jobs/' + jobId + '/download">View / Download</a>' +
" (job " + jobId + ")";
return;
}
if (job.status === "failed") {
scanInFlight = false;
scanBtn.disabled = false;
scanShow("❌ Scan failed: " + (job.error || "unknown reason"), "err");
return;
}
if (job.status === "cancelled") {
scanInFlight = false;
scanBtn.disabled = false;
scanShow("Scan job " + jobId + " was cancelled.", "err");
return;
}
scanShow("⏳ status: " + job.status, "ok");
setTimeout(() => pollScan(jobId, attempt + 1), 2000);
} catch (networkError) {
// One dropped poll shouldn't end monitoring — keep trying.
setTimeout(() => pollScan(jobId, attempt + 1), 3000);
}
}
</script>
</body>
</html>"""
Expand Down
15 changes: 15 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))

Expand Down Expand Up @@ -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")
Loading
Loading