diff --git a/.env.example b/.env.example index 58de782..3cf78e5 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,31 @@ PRINTER_NAME= # Full path to SumatraPDF.exe (Phase 5's PDF printing method). Empty = search # standard install locations. If set here, it is used as-is (no fallback). SUMATRA_PATH= + +# ------------------------------------------------------------------ +# Multi-format printing (docs/MULTI_FORMAT_PLAN.md). Phase 1 stores these; +# each becomes active in the phase that needs it. +# ------------------------------------------------------------------ + +# Paper size for SumatraPDF's print settings. Empty (default) = the driver +# chooses the paper β€” the spike-T4-proven behavior. Set e.g. A4 only after +# spike T5 confirmed this printer honors the setting. Images are laid out +# on A4 when this is empty. +PAPER_SIZE= + +# Office documents (DOC/DOCX/XLS/XLSX/PPT/PPTX/ODF) are converted to PDF by +# LibreOffice Headless (Phase 3). ENABLE_OFFICE=0 turns office formats off +# without uninstalling anything; they are also refused while LibreOffice is +# not installed (its "convert" needs ~400 MB RAM while running). +ENABLE_OFFICE=1 + +# Explicit path to soffice.exe. Empty = search the standard install +# locations (C:\Program Files\LibreOffice\program\soffice.exe). +LO_PATH= + +# Seconds an office conversion may run before the service kills LibreOffice. +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= diff --git a/.gitignore b/.gitignore index 21284cc..55223fb 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ logs/* .coverage .coverage.* htmlcov/ + +# Z-Code +.zcode/ diff --git a/README.md b/README.md index e2f7aa6..fd18616 100644 --- a/README.md +++ b/README.md @@ -1,182 +1,401 @@ -# Printer Service β€” Print from your Android phone, over Wi-Fi +

+ + + + PrinterService Logo + +

+ +

+ Print from your Android phone or any device over Wi-Fi to your USB printer +

+ +

+ + CI + +

+ +A lightweight, self-hosted network printing service built with **Python** and **FastAPI**. It turns any standard USB-connected printer (such as the **Epson L3210**) attached to a Windows PC into a wireless network printer accessible directly from your Android phone or any browser on your local Wi-Fi networkβ€”no manufacturer cloud services, vendor apps, or specialized mobile drivers required. -[![CI](https://github.com/samananias/printerService/actions/workflows/ci.yml/badge.svg)](https://github.com/samananias/printerService/actions/workflows/ci.yml) +--- + +## πŸ“– About The Project + +### The Problem +Most entry-level desktop inkjets (like the Epson L3210) connect only via USB and lack built-in Wi-Fi or network capabilities. Mobile printing from Android typically requires proprietary manufacturer cloud apps, AirPrint/IPP hardware support, or complex network setups. + +### The Solution +**Printer Service** acts as a lightweight software bridge running on a host Windows PC (even low-spec / old PCs). It exposes a mobile-friendly web application and REST API on your local network. When you select a document or image on your phone, the service accepts the upload, standardizes it into a print-ready PDF via dedicated format processors, and silently submits it to the Windows print queue using **SumatraPDF**. Windows and the official Epson driver handle the low-level USB communication and physical printing. + +```text +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Android Phone / Client β”‚ +β”‚ (Browser / Web UI) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ Wi-Fi (HTTP POST /port 8000) + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Windows Host PC (Printer Service) β”‚ +β”‚ β”‚ +β”‚ 1. FastAPI Web Server & REST API (`/`, `/print`, `/jobs`) β”‚ +β”‚ 2. Validation & Security (Magic bytes, Size, PIN Auth) β”‚ +β”‚ 3. Format Processors (Normalize all inputs to PDF): β”‚ +β”‚ β€’ Images (Pillow) ────► PDF β”‚ +β”‚ β€’ Office (LibreOffice) β–Ί PDF β”‚ +β”‚ β€’ TXT / CSV (ReportLab)β–Ί PDF β”‚ +β”‚ β€’ PDF ────────────────► Pass-through β”‚ +β”‚ 4. Print Pipeline & Job Engine (SQLite state tracking) β”‚ +β”‚ 5. SumatraPDF CLI (`-print-to` / `-print-settings`) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ Windows Spooler API + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Windows Print Queue β”‚ +β”‚ Epson L3210 Driver β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ USB Cable + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Epson L3210 Printer β”‚ +β”‚ (Paper Output) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### ✨ Key Features + +- **πŸ“± Zero-Install Mobile Printing**: Open `http://:8000` in Chrome/Firefox on your phone to upload and print instantly. +- **πŸ“„ Multi-Format Conversion**: Seamlessly print PDFs, Photos/Images, Microsoft Office documents, OpenDocument files, and plain text/CSV tables. +- **βš™οΈ Configurable Print Options**: Set number of copies (1–99), specific page ranges (e.g., `2-6`, `odd`/`even`), paper sizes (A4, Letter, Legal, Long Bond, A3, A5), and Color vs. Monochrome modes. +- **πŸ›‘οΈ Pre-Flight Hardware Checks**: Queries Windows spooler status flags (`offline`, `out of paper`, `door open`, `jam`) before dispatching jobs, preventing silent print failures. +- **πŸ”„ Robust Job Lifecycle & Recovery**: Durable job tracking backed by SQLite (`logs/jobs.sqlite3`), with one-click **πŸ” Retry** for failed jobs and support for cancellation. +- **πŸ”’ Local Network Security**: Restricted to private LAN profiles, optional PIN authentication (`API_PIN`), strict file magic-byte validation, and process-tree cleanup. +- **πŸ§ͺ Comprehensive Testing**: 190+ automated tests (~97% coverage) with faked OS/spooler boundaries running in CI, accompanied by standalone hardware diagnostic spikes (T1–T7). + +### πŸ“ Supported Formats -**Project:** Android phone β†’ Wi-Fi β†’ Python service (this PC) β†’ Windows print queue β†’ USB β†’ Epson L3210 -**Status:** βœ… **MVP working end-to-end** β€” a phone upload prints real paper. βœ… Logic verified automatically: pytest suite + ruff lint run in CI on every push. -**Full design document:** [docs/SOURCE_OF_TRUTH.md](docs/SOURCE_OF_TRUTH.md) β€” architecture, concepts, roadmap, testing plan. If it disagrees with this file, it wins. +| Category | Extensions | Processing Method | Details | +|---|---|---|---| +| **PDF** | `.pdf` | Direct pass-through | Native vector submission via SumatraPDF | +| **Images** | `.jpg`, `.jpeg`, `.png`, `.webp`, `.bmp`, `.gif`, `.tiff` | Pillow (PIL) | Automatic EXIF orientation, alpha-to-white transparency, centered on page, 300 DPI cap | +| **Office Documents** | `.docx`, `.xlsx`, `.pptx`, `.doc`, `.xls`, `.ppt`, `.odt`, `.ods`, `.odp` | LibreOffice Headless | High-fidelity headless conversion, isolated throwaway profile, honors stored Excel print areas | +| **Text & Data** | `.txt`, `.csv` | ReportLab | TXT formatted with monospace wrapping and pagination; CSV formatted with styled grid borders and headers | --- -## Repo Map +## πŸ—ΊοΈ Repo Map | Path | What it is | |---|---| -| `app/` | The service: FastAPI app, upload + print pipeline, job tracking, Windows printing | -| `app/api/web.py` | The mobile web page (file picker + Print button) served at `/` | -| `tests/` | pytest suite β€” unit tests (OS boundaries faked) + API tests via TestClient | -| `tests/conftest.py` | Shared fixtures: fresh job store, temp `uploads/`, fake `win32print`, print mock | -| `spike_print_test.py` | Standalone printer diagnostic β€” run it when printing misbehaves | -| `allow_firewall_8000.bat` | One-click firewall rule (run as administrator, once) | -| `.env.example` | Configuration template β€” copy to `.env` (never committed) | -| `requirements.txt` | Python packages: fastapi, uvicorn, pywin32, python-multipart | -| `requirements-dev.txt` | Dev tools: pytest, pytest-cov, httpx, ruff | -| `pyproject.toml` | Tool config: pytest options, coverage gate (90%), ruff lint rules | -| `.github/workflows/ci.yml` | GitHub Actions: lint + tests on every push/PR (Ubuntu) | -| `uploads/`, `logs/` | Runtime temp files (auto-cleaned) and `logs/service.log` | +| `app/` | Core application: FastAPI routes, format processors, upload validation, print pipeline, and job engine | +| `app/api/web.py` | Mobile web application (file upload form, options dialog, live job status) served at `/` | +| `tests/` | Pytest test suite: unit tests (OS boundaries faked) and API tests via FastAPI `TestClient` | +| `tests/conftest.py` | Shared test fixtures: isolated job stores, temporary `uploads/`, mock `win32print`, print mocks | +| `spike_print_test.py` | Core hardware diagnostic spike (T1–T4) β€” validates printer visibility, spooler, and SumatraPDF | +| `spike_t5_images.py` | Image printing spike (T5) β€” tests image conversion, EXIF rotation, and paper size on real hardware | +| `spike_t6_office.py` | Office document spike (T6) β€” tests LibreOffice conversion of DOCX, XLSX, and PPTX | +| `spike_t7_text.py` | Text & CSV spike (T7) β€” tests plain-text wrapping and grid table rendering | +| `allow_firewall_8000.bat` | One-click Windows Firewall script to allow inbound TCP traffic on port 8000 | +| `.env.example` | Configuration template β€” copy to `.env` for local customizations | +| `requirements.txt` | Production dependencies: FastAPI, Uvicorn, pywin32, Pillow, ReportLab, python-multipart | +| `requirements-dev.txt` | Development & testing tools: pytest, pytest-cov, httpx, ruff | +| `pyproject.toml` | Tool configurations: pytest options, coverage thresholds (90%), ruff linting rules | +| `.github/workflows/ci.yml` | Continuous integration workflow: runs ruff and pytest on Ubuntu runners | +| `uploads/`, `logs/` | Runtime directories for temporary upload processing and rotating logs (`logs/service.log`) | +| `docs/logo/` | Project branding assets: SVG icons and wordmark logos (light & dark backgrounds) | --- -## 1. What to Install +## πŸ“¦ 1. Installation & Prerequisites -### The machine that runs the service (the one with printer access) +### Server Machine (The Windows PC connected to the printer) -| Requirement | Why | How | +| Requirement | Purpose | Installation Instructions | |---|---|---| -| **Python 3.12+** | Runs the service | `winget install -e --id Python.Python.3.12` or [python.org](https://www.python.org/downloads/). Verify: `python --version`. If typing `python` opens the Microsoft Store: *Settings β†’ Apps β†’ Advanced app settings β†’ App execution aliases* β†’ turn OFF `python.exe` / `python3.exe` | -| **SumatraPDF** | The PDF printing engine β€” the service hands PDFs to it silently | `winget install SumatraPDF.SumatraPDF` or [sumatrapdfreader.org](https://www.sumatrapdfreader.org). No configuration needed β€” standard install locations are searched automatically | -| **Epson L3210 driver** | Windows must print normally on its own first | Test: *Settings β†’ Printers β†’ Epson L3210 β†’ Print test page*. If that fails, fix it before anything else | -| **Firewall rule, TCP 8000** | The #1 reason phones "can't connect" | Right-click `allow_firewall_8000.bat` β†’ **Run as administrator** (one time), or accept Windows' pop-up on first run (tick *Private networks*) | +| **Python 3.12+** | Runs the Python service | Install via winget: `winget install -e --id Python.Python.3.12` or download from [python.org](https://www.python.org/downloads/).
Verify: `python --version`
*(Note: If typing `python` opens the Microsoft Store: go to **Windows Settings β†’ Apps β†’ Advanced app settings β†’ App execution aliases** and turn **OFF** `python.exe` and `python3.exe`)* | +| **SumatraPDF** | Silent PDF printing engine | Install via winget: `winget install SumatraPDF.SumatraPDF` or download from [sumatrapdfreader.org](https://www.sumatrapdfreader.org). Standard install paths are detected automatically. | +| **Epson L3210 Driver** | Windows printer driver | Ensure Windows can print normally: *Windows Settings β†’ Printers β†’ Epson L3210 β†’ Print test page*. | +| **LibreOffice** *(Optional)* | Office-to-PDF conversion | Required for `.docx`, `.xlsx`, `.pptx`, `.odt`, etc. Without it, office uploads are refused with a friendly message while PDF/image/text printing continues working.
Install via the CLI recipe below or from [libreoffice.org](https://www.libreoffice.org). | +| **Windows Firewall Rule** | Allows LAN traffic on port 8000 | Right-click `allow_firewall_8000.bat` β†’ **Run as administrator** (one-time setup), or allow it on the Windows Defender prompt on first run (select *Private networks*). | -### The phone +#### Installing LibreOffice via CLI (Fast & Scriptable) +Download and run the official MSI installer using PowerShell: -Nothing to install β€” any browser. Same Wi-Fi network as the service PC (guest networks usually isolate devices β€” a classic silent failure). +```powershell +curl.exe -L -o "$env:TEMP\LibreOffice.msi" "https://download.documentfoundation.org/libreoffice/stable/26.8.0/win/x86_64/LibreOffice_26.8.0_Win_x86-64.msi" +msiexec /i "$env:TEMP\LibreOffice.msi" +``` + +A default "Typical" installation installs to `C:\Program Files\LibreOffice\program\soffice.exe`, which the service detects automatically (set `LO_PATH` in `.env` only if installed in a custom path). + +### Phone / Client Device +- **Zero installation needed**: Any modern mobile browser (Chrome, Safari, Firefox). +- **Network requirement**: Must be connected to the **same Wi-Fi network** as the server PC (note: guest Wi-Fi networks usually isolate devices and prevent connection). --- -## 2. One-Time Setup +## πŸš€ 2. One-Time Setup + +Clone or copy the repository to your host PC, open a terminal in the project root folder, and follow the instructions for your preferred shell: -From the project folder: +### Option A: PowerShell / Command Prompt (CMD) ```powershell -python -m venv .venv # create an isolated Python environment -.venv\Scripts\activate # activate it β€” prompt gains (.venv) -pip install -r requirements.txt # install fastapi, uvicorn, pywin32, python-multipart -copy .env.example .env # local config (see Β§5; defaults are fine) +# 1. Create an isolated virtual environment +python -m venv .venv + +# 2. Activate the virtual environment +.venv\Scripts\activate + +# 3. Install required Python packages +pip install -r requirements.txt + +# 4. Create your local configuration file +copy .env.example .env +``` + +> [!TIP] +> **PowerShell execution policy error with `Activate.ps1`?** +> If script execution is restricted, run this command once and retry activating: +> ```powershell +> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser +> ``` + +### Option B: Git Bash + +```bash +# 1. Create an isolated virtual environment +python -m venv .venv + +# 2. Activate the virtual environment (use 'source' and forward slashes) +source .venv/Scripts/activate + +# 3. Install required Python packages +pip install -r requirements.txt + +# 4. Create your local configuration file +cp .env.example .env ``` -> PowerShell blocked `Activate.ps1`? Run once, then retry: -> `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser` -> Git Bash activation: `source .venv/Scripts/activate` +> [!NOTE] +> **Git Bash Tip:** Always use forward slashes `/` and `source`. In Git Bash, backslashes `\` act as escape characters (so `.venv\Scripts\activate` will fail with "command not found"). --- -## 3. Run the Service +## ▢️ 3. Running the Service + +From the project root folder: + +### PowerShell / Command Prompt ```powershell .venv\Scripts\activate uvicorn app.main:app --host 0.0.0.0 --port 8000 ``` -- Keep the window open β€” the service exists only while it runs. `Ctrl+C` stops it. -- `--host 0.0.0.0` is **required** β€” it means "listen on all network interfaces". Omit it and the phone can never connect. -- No-activation one-liner (works from any terminal, any folder): - ```powershell - .venv\Scripts\python.exe -m uvicorn app.main:app --host 0.0.0.0 --port 8000 - ``` -- Working looks like: `INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)` -- Quick self-check from the same PC: open `http://localhost:8000/health` β†’ `{"status":"ok"}` -- Changed the code? Run **Run the checks** below β€” same commands CI runs. +*Or via the direct one-liner (without manual activation):* +```powershell +.venv\Scripts\python.exe -m uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` ---- +### Git Bash -## 4. Print From the Phone +```bash +source .venv/Scripts/activate +uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` -1. Find the service PC's IP: run `ipconfig`, note the **IPv4 Address** (e.g. `192.168.1.5`). Tip: set a **DHCP reservation** for it in the router so it never changes. -2. On the phone (same Wi-Fi): open `http://:8000` -3. Pick a PDF β†’ tap **Print** β†’ watch the status: - `πŸ“¨ Queued… β†’ ⏳ status: queued… β†’ πŸ–¨οΈ Printed to EPSON L3210 Series!` -4. Paper comes out. Done. +*Or via the direct one-liner (without manual activation):* +```bash +.venv\Scripts\python.exe -m uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` -Other endpoints (also browsable interactively at `http://:8000/docs`): +### πŸ’‘ Runtime Notes +- Keep the terminal window openβ€”the service runs as long as the process is active. Press `Ctrl+C` to stop it. +- `--host 0.0.0.0` is **mandatory** because it instructs the server to listen on all local network interfaces so your phone can reach it. +- A successful startup output looks like: `INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)` +- **Self-Check**: On the host PC, open `http://localhost:8000/health` in your browser to verify it returns `{"status":"ok"}`. -| Endpoint | What it does | -|---|---| -| `GET /health` | Is the service up? First thing to check when anything seems broken | -| `GET /printers` | Which printers Windows sees (the L3210 should be listed) | -| `POST /print` | Upload a PDF and print it | -| `GET /jobs` | Recent jobs and their statuses | -| `GET /jobs/{id}` | One job's status (what the page polls) | -| `DELETE /jobs/{id}` | Cancel a job that hasn't printed yet | +--- + +## πŸ“± 4. Printing From Your Phone + +1. **Find the PC's Local IP Address**: + - Open Command Prompt/PowerShell on the server PC and run `ipconfig`. + - Note the **IPv4 Address** (e.g., `192.168.1.5`). + - *(Recommended)* Set a **DHCP reservation** in your home router settings so this IP address never changes. +2. **Open the Web UI on your Phone**: + - Make sure your phone is connected to the same Wi-Fi. + - Open your mobile browser and navigate to `http://:8000` (e.g., `http://192.168.1.5:8000`). +3. **Upload and Print**: + - Select a document or image (PDF, JPG/PNG/WebP, DOCX/XLSX/PPTX, or TXT/CSV). + - Tap **Print**. + - Watch the live progress status: + `πŸ“¨ Queued… β†’ ⏳ status: converting… β†’ πŸ–¨οΈ status: printing… β†’ βœ… Printed to EPSON L3210 Series!` +4. **Formatting Behaviors**: + - **Images**: Automatically fitted and centered on an A4 page with white margins; camera EXIF orientation is respected. + - **Office Files**: Converted via LibreOffice headless (typically 10–30s); UI indicates `converting` state during processing. + - **TXT & CSV**: TXT prints formatted monospace text with automatic line-wrapping; CSV renders a structured, bordered grid table. +5. **Job Retries & Cancellation**: + - If a job fails (e.g., printer out of paper), the uploaded source file is preserved. Tap the **πŸ” Retry** button on the web page or call `POST /jobs/{id}/retry`. + - Active jobs can be cancelled while queued, converting, or printing (best-effort once handed to the Windows spooler). +6. **Print Options Dialog** (Expandable on the web page): + - **Copies**: 1 to 99 copies. + - **Page Range**: Custom ranges (`2-6`, `1,3,5`), `odd`, or `even`. + - **Paper Size**: A4, Short Bond (Letter), Long Bond (8.5Γ—13 / Folio), Legal, A3, A5. + - **Color Mode**: Full Color or Monochrome (Black & White). + - *Options apply across all document formats and are retained when retrying a job.* + +### 🌐 API Reference + +The service provides a full REST API, also browsable interactively via Swagger UI at `http://:8000/docs`: + +| Endpoint | Method | Description | +|---|---|---| +| `/health` | `GET` | Server health check (`{"status": "ok"}`). Ideal for reachability verification. | +| `/printers` | `GET` | Lists all printers detected by Windows via `pywin32`. | +| `/print` | `POST` | Upload and queue a file for printing (accepts optional print settings). | +| `/jobs` | `GET` | Lists recent print jobs and their current execution statuses. | +| `/jobs/{id}` | `GET` | Fetches detailed status and metadata for a specific print job. | +| `/jobs/{id}` | `DELETE` | Cancels an active or queued print job. | +| `/jobs/{id}/retry` | `POST` | Re-dispatches a failed job using its stored upload without re-uploading. | --- -## 5. Configuration (`.env`) +## βš™οΈ 5. Configuration (`.env`) -Copy `.env.example` β†’ `.env` and edit. All values are optional; defaults work. +Copy `.env.example` to `.env` to configure optional server settings. All keys are optionalβ€”the default configuration is ready for immediate use: -| Key | Default | Meaning | +| Key | Default | Description | |---|---|---| -| `MAX_UPLOAD_MB` | `25` | Upload size limit (bigger files are rejected with HTTP 413) | -| `API_PIN` | *(empty)* | If set, printing/cancelling requires the PIN (sent as `X-API-PIN`; the web page has a PIN field). Empty = no auth | -| `PRINTER_NAME` | *(empty)* | Target printer. Empty = Windows' default printer | -| `SUMATRA_PATH` | *(empty)* | Explicit path to `SumatraPDF.exe`. Empty = search standard locations. If set, used as-is (misconfiguration fails loudly) | -| `HOST`, `PORT` | `8000` | Informational β€” actually pass them on the uvicorn command line (Β§3) | +| `MAX_UPLOAD_MB` | `25` | Maximum allowed upload size in megabytes (larger files rejected with HTTP 413). | +| `API_PIN` | *(empty)* | Optional security PIN. When set, requests require the `X-API-PIN` header (the web interface will display a PIN input field). | +| `PRINTER_NAME` | *(empty)* | Specific Windows printer name to target. If empty, the system default printer is used. | +| `SUMATRA_PATH` | *(empty)* | Custom path to `SumatraPDF.exe`. Leave empty to use automatic standard path detection. | +| `PAPER_SIZE` | *(empty)* | Default paper size token passed to driver (e.g. `A4`). Leave empty to let the Windows driver choose. | +| `ENABLE_OFFICE` | `1` | Enables office file processing (`.docx`, `.xlsx`, `.pptx`, `.odt`). Set to `0` to disable office conversion. | +| `LO_PATH` | *(empty)* | Custom path to LibreOffice `soffice.exe`. Leave empty to use automatic discovery. | +| `CONVERT_TIMEOUT_S` | `120` | Maximum timeout in seconds for LibreOffice conversion before terminating the process tree. | +| `JOB_DB_PATH` | `logs/jobs.sqlite3` | Path to the SQLite job history database. Delete the file at any time to reset job history. | +| `HOST`, `PORT` | `8000` | Reference settings (actual binding host and port are passed via the `uvicorn` CLI command). | + +> [!NOTE] +> - **Pre-Flight Checks**: The service verifies spooler status (`offline`, `out of paper`, `door open`, `error`) before dispatching jobs. +> - **SumatraPDF Exit Codes**: Exit codes (2 = corrupt file, 4 = printer not found, 5 = driver error) are translated into clear, human-readable explanations. +> - **Log Management**: Log files in `logs/service.log` automatically rotate at ~1 MB with 2 backup archives kept. --- -## 6. Diagnostics: the Printer Spike +## 🩺 6. Diagnostics & Hardware Spikes + +If printing ever fails or you want to verify your setup before launching the service, use the standalone diagnostic spike scripts: -If printing ever misbehaves, `spike_print_test.py` checks each link of the chain separately (needs `pip install pywin32` on the machine it runs on): +### Core Printer Diagnostic (T1–T4) +Run from the project root (requires `pywin32`): ```powershell python spike_print_test.py ``` -It reports: printer visibility (T1), spooler acceptance (T2), Windows print-verb (T3), SumatraPDF (T4) β€” with a summary and "what to do with this result" guidance. **T4 passing + paper = the whole chain works.** See SOURCE_OF_TRUTH Section 5 for the recorded results that decided the current design. +This diagnostic systematically checks each layer: +- **T1**: Verifies printer visibility through `win32print`. +- **T2**: Tests RAW text submission to the Windows spooler. +- **T3**: Checks the Windows "print" shell verb. +- **T4**: Tests silent PDF printing through SumatraPDF to the physical printer. +- **Result:** T4 PASS with a printed test page confirms the entire Python β†’ SumatraPDF β†’ Spooler β†’ USB chain is functional. ---- +### Multi-Format Acceptance Spikes +- **`spike_t5_images.py`**: Tests ImageProcessor conversion (JPEG, PNG with transparency, WebP, EXIF orientation) and prints to paper. +- **`spike_t6_office.py`**: Tests OfficeProcessor (DOCX tables, landscape XLSX with print areas, 16:9 PPTX) using LibreOffice. +- **`spike_t7_text.py`**: Tests TextProcessor rendering of wrapped text and CSV tables. -## 7. Deploying to the Print-Server PC (final step) +--- -1. Install **Python 3.12+** and **SumatraPDF** on that PC (Β§1) -2. Get the code there: `git clone` / `git pull`, or copy the folder (delete `.venv` first β€” recreate it there with Β§2) -3. `pip install -r requirements.txt`, `copy .env.example .env` -4. Run `allow_firewall_8000.bat` **as administrator** on that PC -5. Run `python spike_print_test.py` there once β€” confirm T1/T2/T4 pass with paper -6. Start the service (Β§3) and test from the phone using **that PC's IP** (`ipconfig`) -7. Set the router's **DHCP reservation** for that PC so its IP is stable -8. Auto-start on boot (optional): Task Scheduler β†’ *Create Task* β†’ trigger "At startup" β†’ action: `C:\...\printerService\.venv\Scripts\python.exe` with arguments `-m uvicorn app.main:app --host 0.0.0.0 --port 8000`, start-in the project folder +## πŸ–₯️ 7. Deploying to the Dedicated Print-Server PC + +Follow these steps when setting up the service permanently on your print-server machine: + +1. **Install Prerequisites**: Install **Python 3.12+**, **SumatraPDF**, printer drivers, and optionally **LibreOffice** (Β§1). +2. **Obtain the Code**: Clone the repository with `git clone` or copy the project folder (ensure you delete any existing `.venv` folder before copying). +3. **Set Up Virtual Environment**: + ```powershell + python -m venv .venv + .venv\Scripts\activate + pip install -r requirements.txt + copy .env.example .env + ``` +4. **Configure Firewall**: Run `allow_firewall_8000.bat` **as Administrator** to open TCP port 8000. +5. **Run Diagnostics**: Run `python spike_print_test.py` to confirm physical paper prints successfully. +6. **Start the Server**: Launch the service using the instructions in Β§3 and verify connectivity from your phone. +7. **Reserve Static IP**: In your home router configuration, create a **DHCP reservation** for the PC's MAC address to keep its IP permanent. +8. **Configure Auto-Start on Boot (Optional via Windows Task Scheduler)**: + - Open **Task Scheduler** β†’ *Create Task*. + - **General**: Name the task (e.g., `PrinterService`), check *Run whether user is logged on or not*. + - **Triggers**: Set trigger to **At startup**. + - **Actions**: Set action to **Start a program**: + - *Program/script*: `C:\path\to\printerService\.venv\Scripts\python.exe` + - *Add arguments*: `-m uvicorn app.main:app --host 0.0.0.0 --port 8000` + - *Start in*: `C:\path\to\printerService\` --- -## 8. When Something Breaks - -| Symptom | Likely cause / fix | -|---|---| -| Phone can't connect at all (page hangs) | Firewall rule missing (Β§1), or phone on a different Wi-Fi/guest network | -| `error 10048` on startup | Port 8000 taken by another program β€” close it or use `--port 8001` (and that port on the phone) | -| Phone reaches `/health` but print fails | Read the error on the page or in `logs/service.log`; run the spike (Β§6) | -| Job `failed`: SumatraPDF not found | Install SumatraPDF (Β§1) or set `SUMATRA_PATH` in `.env` | -| Job `failed`: printer not default / offline | Check the printer in Windows, print a Windows test page | -| Service IP changed after reboot | Set the router's DHCP reservation (Β§7 step 7) | +## πŸ› οΈ 8. Troubleshooting & Common Issues -Full troubleshooting table: [docs/SOURCE_OF_TRUTH.md](docs/SOURCE_OF_TRUTH.md) Section 14. Debug in this order β€” connectivity (IP/port) β†’ firewall β†’ service β†’ printing logic (Section 15 explains why). +| Symptom | Likely Cause | Recommended Fix | +|---|---|---| +| Phone browser hangs / cannot connect | Missing Windows Firewall rule or wrong network | Run `allow_firewall_8000.bat` as Admin (Β§1). Ensure phone is on the same private Wi-Fi network (not a guest network). | +| `error 10048` on server startup | Port 8000 is already in use | Terminate the conflicting application or start Uvicorn with `--port 8001` (and use port 8001 on the phone). | +| Phone reaches `/health` but print job fails | Printer offline or driver error | Check printer power/cable, verify test print in Windows Settings, and check error messages in `logs/service.log`. | +| Job fails: `SumatraPDF not found` | SumatraPDF is not installed in a standard location | Install SumatraPDF via winget (Β§1) or set the exact binary path in `SUMATRA_PATH` in `.env`. | +| Job fails: `Printer offline / out of paper` | Pre-flight readiness check failed | Ensure printer is turned on, paper is loaded in tray, and clear any paper jams, then click **πŸ” Retry**. | +| Job fails: `Printer not default / not found` | Printer name mismatch | Set the exact printer name under `PRINTER_NAME` in `.env` matching Windows printer settings. | +| Office upload rejected: `LibreOffice not installed` | Missing LibreOffice or `ENABLE_OFFICE=0` | Install LibreOffice (Β§1) or verify `ENABLE_OFFICE=1` in `.env`, then restart the service. | +| Office conversion fails: `Timeout after 120 s` | Large or complex document | Increase `CONVERT_TIMEOUT_S` in `.env` or export to PDF directly on your client device. | +| Office print layout looks altered (fonts/margins) | Missing server-side fonts or missing print area | Install standard font packs on Windows server; for spreadsheets, define an explicit Print Area in Excel. | +| SumatraPDF fails with exit codes `2`, `4`, or `5` | File corrupt (`2`), printer missing (`4`), or driver error (`5`) | Review detailed logs in `logs/service.log` and verify the PDF renders in SumatraPDF directly. | +| Server IP address changes after PC reboot | Dynamic DHCP re-assignment | Set a static IP or configure a DHCP reservation on your home router (Β§7). | + +> [!TIP] +> **Recommended Debugging Hierarchy**: Always diagnose issues from the bottom up: +> 1. Physical Hardware & Drivers (USB cable, paper, Windows test page) +> 2. Network Connectivity (IP address, ping, same Wi-Fi subnet) +> 3. Firewall & Ports (Inbound rules on port 8000) +> 4. Application Logic & Service Logs (`logs/service.log`, `http://localhost:8000/health`) --- -## Run the Checks (what CI runs) +## πŸ§ͺ 9. Running Tests & Developer Checks -Changed the service code? Two commands verify the logic β€” without printing anything: +Verify code quality and ensure test coverage before committing changes (mirrors CI workflow): +### PowerShell / Command Prompt ```powershell .venv\Scripts\activate -pip install -r requirements-dev.txt # once per machine -ruff check . # lint: unused imports, undefined names, style drift -pytest # the suite + coverage report (fails below the 90% gate) +pip install -r requirements-dev.txt # Install testing dependencies (once) +ruff check . # Linting: unused imports, style rules, bugs +pytest # Executes full test suite with coverage report (β‰₯90% gate) ``` -- **Unit tests** (`tests/unit/`) exercise validation, job tracking, PIN auth, and the printing decisions with every OS boundary faked β€” no printer, no SumatraPDF, no network needed. -- **API tests** (`tests/api/`) drive the whole FastAPI app through a test client, the same requests the phone makes. -- Tests never touch machine state (real `uploads/` is redirected to a temp dir), so they run identically on your PC and on CI's Ubuntu runner. -- Real paper **can't** be tested by CI β€” no printer is attached to it. The spike (Β§6) stays the hardware test. -- GitHub Actions runs both commands on every push and pull request (`.github/workflows/ci.yml`); the badge at the top shows the latest result. +### Git Bash +```bash +source .venv/Scripts/activate +pip install -r requirements-dev.txt # Install testing dependencies (once) +ruff check . # Linting: unused imports, style rules, bugs +pytest # Executes full test suite with coverage report (β‰₯90% gate) +``` + +### Test Architecture Highlights +- **Unit Tests (`tests/unit/`)**: Verify file validation, magic-byte detection, format processors, and pipeline state transitions with all OS interactions (subprocess, file system, `win32print`) completely faked. +- **API Tests (`tests/api/`)**: Simulate real client and phone requests against FastAPI endpoints using `httpx` and `TestClient` without network overhead. +- **Hardware Isolation**: Automated tests never touch the real printer or filesystem state, enabling the exact same test suite to run identically on Windows development machines and Ubuntu CI runners. +- **CI Automation**: GitHub Actions runs `ruff check` and `pytest` with a 90% coverage threshold on every push and pull request (`.github/workflows/ci.yml`). --- -## Where to Go Next +## πŸ“š 10. Documentation & References + +For deep dives into architectural decisions, roadmap phases, and design rationales, refer to the project documentation: -- **Roadmap & current phase** β†’ [docs/SOURCE_OF_TRUTH.md](docs/SOURCE_OF_TRUTH.md) Section 9 -- **Why it's built this way** β†’ Sections 3–8 there (protocol choice, tech stack, security, scope) -- **API design** β†’ Section 11 Β· **Testing plan + how the automated suite fits in** β†’ Section 13 -- **How the test fixtures work** β†’ the commented `tests/conftest.py` Β· **What CI runs** β†’ `.github/workflows/ci.yml` +- **[docs/SOURCE_OF_TRUTH.md](docs/SOURCE_OF_TRUTH.md)**: The authoritative architecture document, technology stack rationale, network security rules, and detailed failure diagnostic guides. +- **[docs/MULTI_FORMAT_PLAN.md](docs/MULTI_FORMAT_PLAN.md)**: Multi-format design record, decision logs (Pillow, LibreOffice, ReportLab), and the T5–T7 spike protocols. +- **[tests/conftest.py](tests/conftest.py)**: Reference for test fixture implementations and Windows printing mocks. diff --git a/app/api/jobs.py b/app/api/jobs.py index 2d7affe..bded906 100644 --- a/app/api/jobs.py +++ b/app/api/jobs.py @@ -1,20 +1,25 @@ """ -Jobs API (Phase 7) β€” see what happened, poll status, cancel mistakes. +Jobs API (Phase 7; queue management in p14) β€” see what happened, poll +status, cancel mistakes, retry failures. -Endpoints (SOURCE_OF_TRUTH Section 11): - GET /jobs β€” recent jobs and their statuses - GET /jobs/{id} β€” one job (what the phone UI polls: "done yet?") - DELETE /jobs/{id} β€” cancel a job that hasn't reached the print queue +Endpoints (SOURCE_OF_TRUTH Section 11, extended by p14): + GET /jobs β€” recent jobs and their statuses + GET /jobs/{id} β€” one job (what the phone UI polls: "done yet?") + DELETE /jobs/{id} β€” cancel a job (queued/converting/printing) + POST /jobs/{id}/retry β€” re-print a failed job from its stored upload """ +import logging + from fastapi import APIRouter, Depends, HTTPException -from app.models.printing import PrintJob -from app.services import jobs +from app.models.printing import JobStatus, PrintJob +from app.printer import windows +from app.services import jobs, pipeline, uploads from app.services.auth import require_pin -from app.services.uploads import upload_path router = APIRouter() +logger = logging.getLogger(__name__) @router.get("/jobs", response_model=list[PrintJob]) @@ -34,19 +39,70 @@ def one_job(job_id: str): @router.delete("/jobs/{job_id}", response_model=PrintJob) def cancel(job_id: str, _: None = Depends(require_pin)): - """Cancel a queued job (Section 11: "you will queue the wrong file").""" + """Cancel a job (Section 11: "you will queue the wrong file"). + + p14: cancellation works while queued, converting AND printing. The + printing case is best-effort β€” our queued spooler jobs are purged via + win32print, but paper that already fed into the printer cannot be + recalled; the pipeline never marks a cancelled job done. + """ job = jobs.get_job(job_id) if job is None: raise HTTPException(status_code=404, detail=f"No job with id '{job_id}'.") + was_printing = job.status == JobStatus.PRINTING ok, message = jobs.cancel_job(job_id) if not ok: raise HTTPException(status_code=409, detail=message) - # Remove the stored file so cancelled uploads don't fill the disk. - try: - upload_path(job_id).unlink(missing_ok=True) - except OSError: - pass # cleanup failure must not fail the cancel + # Best-effort spooler purge for anything already handed to Windows. + # Never fails the cancel β€” a purge hiccup must not 500 the request. + if was_printing: + try: + removed = windows.cancel_spooler_jobs( + windows.resolve_printer_name(), job_id + ) + if removed: + logger.info( + "purged %d spooler job(s) for cancelled job %s", removed, job_id + ) + except Exception: + logger.warning( + "spooler purge failed for cancelled job %s", job_id, exc_info=True + ) + + # Remove the stored file(s) so cancelled uploads don't fill the disk. + # delete_job_files covers the source upload and its converted PDF. + uploads.delete_job_files(job_id) + + return jobs.get_job(job_id) + + +@router.post("/jobs/{job_id}/retry", response_model=PrintJob) +def retry(job_id: str, _: None = Depends(require_pin)): + """Re-print a failed job (p14). + + Failed jobs keep their uploaded file precisely for this. The pipeline + re-runs from conversion β€” a transient failure (printer offline, a + LibreOffice hiccup) becomes a second chance without re-uploading. + """ + job = jobs.get_job(job_id) + if job is None: + raise HTTPException(status_code=404, detail=f"No job with id '{job_id}'.") + if job.status != JobStatus.FAILED: + raise HTTPException( + status_code=409, + detail=f"Job is '{job.status}' β€” only failed jobs can be retried.", + ) + + source = jobs.get_source(job_id) + if source is None or not source[0].is_file(): + raise HTTPException( + status_code=409, + detail="The uploaded file for this job is gone β€” upload it again.", + ) + jobs.reset_for_retry(job_id) + pipeline.start_job(job_id, source[0], source[1], options=job.options) + logger.info("job %s queued for retry", job_id) return jobs.get_job(job_id) diff --git a/app/api/print.py b/app/api/print.py index 1bddd9d..c295256 100644 --- a/app/api/print.py +++ b/app/api/print.py @@ -1,67 +1,97 @@ """ -POST /print β€” accept a PDF upload (Phase 4). +POST /print β€” accept a printable file (Phase 4; multi-format in p10). -Printing itself arrives in Phase 5; this endpoint proves the *transfer* half -of the pipeline: phone β†’ HTTP β†’ validated bytes on disk, intact. +Printing itself arrives in Phase 5's background thread; this endpoint +proves the *transfer* half of the pipeline: phone β†’ HTTP β†’ validated bytes +on disk, intact. + +Phase 1 (multi-format refactor) keeps the behavior PDF-only, but the flow +is now format-agnostic (docs/MULTI_FORMAT_PLAN.md Β§8): + + 1. FastAPI/python-multipart parse the request and hand us the bytes. + 2. validate_upload() applies the Section 8 checks (type, content, + availability, size) and returns the detected category. A category + prints once its processor is registered AND available on this + machine (PDF, images and text always; office additionally needs + LibreOffice installed / ENABLE_OFFICE=1). Refusals explain which + gate fired. + 3. save_upload() stores the bytes under a unique job id, keeping the + real extension. + 4. The job is registered (category recorded) and handed to the + background pipeline: convert β†’ print β†’ cleanup. + +Returns 201 with the job id. Errors: 401 (bad PIN), 415 (unsupported file +or lying extension), 413 (too large), 500 (disk trouble). """ import logging +from pathlib import Path -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile -from app.models.printing import PrintAccepted +from app.detection import DEFAULT_EXTENSIONS +from app.models.printing import PrintAccepted, validate_print_options from app.services import jobs, pipeline from app.services.auth import require_pin -from app.services.uploads import UploadError, save_upload, validate_pdf +from app.services.uploads import UploadError, save_upload, validate_upload router = APIRouter() logger = logging.getLogger(__name__) @router.post("/print", response_model=PrintAccepted, status_code=201) -async def print_pdf( +async def print_file( file: UploadFile = File(...), + copies: int = Form(1), + pages: str = Form(""), + paper: str = Form(""), + color_mode: str = Form("color"), _: None = Depends(require_pin), # PIN required only when API_PIN is set ): - """Accept a PDF exactly like a web form uploads a photo: a + """Accept a file exactly like a web form uploads a photo: a multipart/form-data POST whose file field is named "file". - Flow (SOURCE_OF_TRUTH Section 5, stages 2-7): - 1. FastAPI/python-multipart parse the request and hand us the bytes. - 2. validate_pdf() applies the Section 8 checks (type, size). - 3. save_upload() stores them under a unique job_id in uploads/. - 4. The job is registered in the in-memory tracker (Phase 7). - 5. pipeline.start_job() submits it to the Windows print queue in a - background thread; status moves queued β†’ done/failed there. - - Returns 201 with the job id. Errors: 401 (bad PIN), 415 (not a PDF), - 413 (too large), 500 (disk trouble). - """ + The print options (copies, pages, paper, color_mode) are all optional + with safe defaults β€” Phase 7; see PrintOptions for the allowlists.""" data = await file.read() + filename = file.filename or "" try: - validate_pdf(file.filename or "", data) + category = validate_upload(filename, data) except UploadError as exc: - logger.warning("rejected upload %r: %s", file.filename, exc) + logger.warning("rejected upload %r: %s", filename, exc) raise HTTPException(status_code=exc.status_code, detail=str(exc)) try: - job_id, path = save_upload(data) + options = validate_print_options(copies, pages, paper, color_mode).model_dump() + except ValueError as exc: + logger.warning("rejected upload %r: bad print options: %s", filename, exc) + raise HTTPException(status_code=422, detail=str(exc)) + + # Store under the real extension; a client that sent no usable filename + # gets the canonical one for its (magic-proven) category. + ext = Path(filename).suffix.lower() or DEFAULT_EXTENSIONS[category] + + try: + job_id, path = save_upload(data, ext=ext) except OSError as exc: # Disk full, permissions, antivirus blocking writes... a clean 500 # beats an unhandled exception crashing the request (Section 14). - logger.exception("could not store upload %r", file.filename) + logger.exception("could not store upload %r", filename) raise HTTPException(status_code=500, detail=f"Could not store upload: {exc}") - jobs.create_job(job_id, file.filename or "unknown.pdf", len(data), path) - pipeline.start_job(job_id, path) + jobs.create_job( + job_id, filename or f"unknown{ext}", len(data), path, format=category, + options=options, + ) + pipeline.start_job(job_id, path, category, options=options) logger.info( - "job %s received: %s (%d bytes)", job_id, file.filename, len(data) + "job %s received: %s (%d bytes, %s)", job_id, filename, len(data), category ) return PrintAccepted( job_id=job_id, status="queued", - filename=file.filename or "unknown.pdf", + filename=filename or f"unknown{ext}", size_bytes=len(data), ) diff --git a/app/api/web.py b/app/api/web.py index 527d72d..20356b4 100644 --- a/app/api/web.py +++ b/app/api/web.py @@ -3,7 +3,9 @@ The page is a single self-contained HTML string (inline CSS + vanilla JS β€” no build tools, no frameworks) served directly by FastAPI. The phone's -browser IS the app: open http://:8000, pick a PDF, tap Print. +browser IS the app: open http://:8000, pick a PDF or image, tap +Print. The accept list mirrors the categories registered in +app/processors β€” new formats update both. How the upload works (worth reading slowly β€” this is HTTP from the browser's point of view): @@ -18,15 +20,44 @@ """ from fastapi import APIRouter -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, Response router = APIRouter() +FAVICON_SVG = """ + PrinterService + A smartphone feeding into a printer printing a Wi-Fi page. + + + + + + + + + + + + + + + + + + + +""" + PAGE = """ + + Printer Service
-

πŸ–¨οΈ Printer Service

-

Pick a PDF and send it to the printer.

+
+ PrinterService Logo +

Printer Service

+
+

Pick a PDF, image, Office, or text file and send it to the printer.

- +
+ + +
+ + Print options + + + + + +