feat(scan): support iX1600 duplex scanning on Linux

This commit is contained in:
2026-09-17 18:22:43 +02:00
parent cfef07b96d
commit dd37622bb6
24 changed files with 1411 additions and 215 deletions

View File

@@ -4,3 +4,5 @@ dist
.env .env
*.log *.log
*.tmp *.tmp
__pycache__
tests

View File

@@ -8,6 +8,7 @@ FEDEO_SCAN_FORMAT=pdf
FEDEO_SCAN_RESOLUTION=300 FEDEO_SCAN_RESOLUTION=300
FEDEO_SCAN_MODE=Color FEDEO_SCAN_MODE=Color
FEDEO_SCAN_SOURCE= FEDEO_SCAN_SOURCE=
FEDEO_SCAN_TIMEOUT_SECONDS=900
FEDEO_SCAN_POSTPROCESS=false FEDEO_SCAN_POSTPROCESS=false
FEDEO_SCAN_POSTPROCESS_PROFILE=document FEDEO_SCAN_POSTPROCESS_PROFILE=document
FEDEO_SCAN_POSTPROCESS_PYTHON= FEDEO_SCAN_POSTPROCESS_PYTHON=

View File

@@ -4,3 +4,4 @@ node_modules
.env .env
*.log *.log
*.tmp *.tmp
__pycache__/

View File

@@ -2,8 +2,8 @@ FROM node:20-bookworm-slim AS build
WORKDIR /app WORKDIR /app
COPY package.json tsconfig.json ./ COPY package.json package-lock.json tsconfig.json ./
RUN npm install RUN npm ci
COPY src ./src COPY src ./src
RUN npm run build RUN npm run build
@@ -12,8 +12,8 @@ FROM node:20-bookworm-slim AS runtime
ENV NODE_ENV=production ENV NODE_ENV=production
ENV FEDEO_WORK_DIR=/work ENV FEDEO_WORK_DIR=/work
ENV FEDEO_SCAN_POSTPROCESS=true ENV FEDEO_SCAN_POSTPROCESS=false
ENV FEDEO_SCAN_POSTPROCESS_PROFILE=receipt ENV FEDEO_SCAN_POSTPROCESS_PROFILE=document
ENV FEDEO_SCAN_POSTPROCESS_PYTHON=/opt/fedeo-device-agent/.venv-opencv/bin/python ENV FEDEO_SCAN_POSTPROCESS_PYTHON=/opt/fedeo-device-agent/.venv-opencv/bin/python
ENV FEDEO_SCAN_POSTPROCESS_STRICT=false ENV FEDEO_SCAN_POSTPROCESS_STRICT=false

View File

@@ -13,6 +13,8 @@ brew install node sane-backends
scanimage -L scanimage -L
``` ```
Für die Dateierstellung wird Python mit Pillow benötigt, beispielsweise über `npm run setup:opencv` (siehe unten).
Drucken nutzt später das macOS-Drucksystem/CUPS: Drucken nutzt später das macOS-Drucksystem/CUPS:
```bash ```bash
@@ -23,11 +25,52 @@ lpstat -p
```bash ```bash
sudo apt update sudo apt update
sudo apt install -y nodejs npm sane-utils cups sudo apt install -y nodejs npm sane-utils python3-pil
scanimage -L scanimage -L
lpstat -p
``` ```
Node.js muss mindestens Version 20 haben (`node --version`). CUPS ist nur für die optionale Druckererkennung erforderlich.
## ScanSnap iX1600 unter Linux per USB
Der iX1600 wird vom SANE-Backend `fujitsu` per USB unterstützt. Die USB-ID ist `04c5:1632`; die WLAN-Schnittstelle wird von diesem Backend nicht unterstützt. Quellen: [SANE-Geräteliste](https://sane-project.gitlab.io/website/sane-mfgs.html), [Fujitsu-Backend](https://sane-project.gitlab.io/website/man/sane-fujitsu.5.html).
1. Scanner einschalten und per USB anschließen. `lsusb -d 04c5:1632` und `scanimage -L` müssen ihn anzeigen. Den vollständigen Gerätenamen übernehmen, beispielsweise `fujitsu:ScanSnap iX1600:123456`.
2. In FEDEO unter **Administration → Scanner** einen Agenten anlegen und den einmalig angezeigten Token sichern.
3. Im Verzeichnis `agents/fedeo-device-agent` die Abhängigkeiten installieren und konfigurieren:
```bash
npm ci
cp .env.example .env
chmod 600 .env
nano .env
```
```env
FEDEO_URL=https://deine-fedeo-instanz
FEDEO_AGENT_TOKEN=fedeo_agent_DEIN_TOKEN
FEDEO_SCANNER_NAME="fujitsu:ScanSnap iX1600:DEINE_SERIENNUMMER"
FEDEO_SCAN_SOURCE="ADF Duplex"
FEDEO_SCAN_FORMAT=pdf
FEDEO_SCAN_RESOLUTION=300
FEDEO_SCAN_MODE=Color
FEDEO_SCAN_POSTPROCESS=false
FEDEO_SCAN_TIMEOUT_SECONDS=900
```
`FEDEO_URL` ist die Basis-URL, unter der `/instance-agent/heartbeat` erreichbar ist, ohne zusätzliches `/api`.
4. `npm run build` und `npm start` ausführen. In FEDEO muss der Agent als online erscheinen.
5. In der Scannerverwaltung das erkannte Gerät wählen, **ScanSnap iX1600: A4, Duplex, PDF** anklicken und speichern.
6. Papier einlegen. Unter **Dateien → Scan** den Agenten wählen und **Scannen** anklicken. Der gesamte Stapel landet als ein PDF im aktuellen Ordner. Für den ersten Test zwei Blätter mit markierten Vorder- und Rückseiten verwenden: Das PDF muss vier Seiten in dieser Reihenfolge enthalten.
Der Agent liest im SANE-Batch-Modus bis zum leeren Einzug. Vorder- und Rückseiten werden in der vom Scanner gelieferten Reihenfolge gespeichert, einschließlich leerer Rückseiten. Es gibt keine automatische Leerseitenentfernung. „ADF Front“ scannt einen Stapel einseitig, „Flatbed“ eine einzelne Seite. Einzug-Scans benötigen PDF; PNG und TIFF sind für Einzelseiten verfügbar. Die Optionen entsprechen dem [scanimage-Handbuch](https://sane-project.gitlab.io/website/man/scanimage.1.html).
**Papierformat:** Das iX1600-Profil verwendet A4 mit 210 × 297 mm, einschließlich der Fujitsu-Einstellungen für die Papiergröße. Für andere Größen die Einstellung anpassen; „Scanner-Vorgabe“ übernimmt den am Treiber eingestellten Scanbereich. Die SANE-Quellen und Optionen lassen sich ohne Scan mit `scanimage -d 'DEIN_GERAETENAME' --source 'ADF Duplex' --help` prüfen.
**USB-Zugriff:** Wenn `lsusb` das Gerät sieht, `scanimage -L` jedoch nicht, zuerst Kabel, Stromversorgung, SANE-Version und Zugriffsrechte prüfen. Debian/Ubuntu/Mint liefern normalerweise passende udev-Regeln mit `libsane-common`. Andere Scanprogramme schließen. Einen dauerhaft laufenden Agenten unter einem Benutzer betreiben, der ebenfalls mit `scanimage -L` auf das Gerät zugreifen kann.
**Lange Stapel und Fehler:** Das Scan-Zeitlimit beträgt standardmäßig 15 Minuten; die Dateierstellung hat ein eigenes gleich langes Limit. Der Agent hält seinen Online-Status währenddessen aktuell. Der Dialog kann im Hintergrund weiterlaufen; Schließen stoppt den Scan nicht. Verbindungsfehler beim Abfragen des Status lösen keinen neuen Scan aus. Bei Papierstau, unvollständigem Duplex-Scan oder Uploadfehler wird kein automatischer erneuter Papiereinzug gestartet. Die Rohseiten bzw. die fertige Datei bleiben im gemeldeten Arbeitsverzeichnis zur Wiederherstellung erhalten. Nach erfolgreichem Upload werden sie gelöscht. Das Upload-Limit beträgt 100 MiB pro PDF; gegebenenfalls den Stapel teilen oder die Auflösung reduzieren.
## Konfiguration ## Konfiguration
```bash ```bash
@@ -49,7 +92,7 @@ Wenn `FEDEO_SCANNER_NAME` leer bleibt, verwendet `scanimage` den Standard-Scanne
## Entwicklung ## Entwicklung
```bash ```bash
npm install npm ci
npm run dev npm run dev
``` ```
@@ -70,7 +113,7 @@ FEDEO_SCAN_POSTPROCESS_PYTHON=/pfad/zum/agent/.venv-opencv/bin/python
FEDEO_SCAN_POSTPROCESS_STRICT=false FEDEO_SCAN_POSTPROCESS_STRICT=false
``` ```
Wenn `FEDEO_SCAN_POSTPROCESS_PYTHON` leer bleibt, verwendet der Agent automatisch `.venv-opencv/bin/python`, sofern diese Umgebung existiert. Falls OpenCV nicht installiert ist und `FEDEO_SCAN_POSTPROCESS_STRICT=false` gesetzt ist, lädt der Agent den Rohscan hoch, statt den Auftrag komplett fehlschlagen zu lassen. Wenn `FEDEO_SCAN_POSTPROCESS_PYTHON` leer bleibt, verwendet der Agent automatisch `.venv-opencv/bin/python`, sofern diese Umgebung existiert, sonst `python3`. Pillow ist auch ohne Bildkorrektur für die Dateierstellung erforderlich. Falls OpenCV nicht installiert ist und `FEDEO_SCAN_POSTPROCESS_STRICT=false` gesetzt ist, werden die Rohseiten im gewünschten Ausgabeformat zusammengeführt. Eine fehlgeschlagene Korrektur verliert keine Seiten. Die Warnung erscheint im Scan-Ergebnis. Beim iX1600-Profil ist die Bildkorrektur zunächst ausgeschaltet, damit der vollständige A4-Scan erhalten bleibt.
Profile: Profile:
@@ -109,6 +152,17 @@ npm run build
npm start npm start
``` ```
## Tests
```bash
npm test
npm run test:python
```
Die Tests benötigen Python 3 mit Pillow. Sie simulieren `scanimage`, erstellen echte PDFs und prüfen Duplex-/Simplex-Stapel, Seitenreihenfolge, Auflösung, leeren Einzug, Papierstau, beschädigte Seiten, Dateinamen und OpenCV-Ausfälle. Ein Test mit angeschlossenem iX1600 bleibt zusätzlich erforderlich.
Hardwareprüfung am 17.09.2026: ScanSnap iX1600 per USB unter Linux Mint 22.3 mit SANE 1.2.1. Zwei A4-Blätter wurden mit 300 dpi in Farbe beidseitig gescannt; das erzeugte PDF enthält vier A4-Seiten in der Reihenfolge Vorderseite/Rückseite. Alle vier Seiten wurden visuell geprüft. Der Test umfasste den lokalen Agenten bis zur PDF-Erstellung; der FEDEO-Upload wird separat mit einem simulierten Dateispeicher getestet.
## FEDEO-Endpunkte ## FEDEO-Endpunkte
Der Agent nutzt: Der Agent nutzt:

View File

@@ -9,8 +9,6 @@ services:
- .env - .env
environment: environment:
FEDEO_WORK_DIR: /work FEDEO_WORK_DIR: /work
FEDEO_SCAN_POSTPROCESS: "true"
FEDEO_SCAN_POSTPROCESS_PROFILE: receipt
FEDEO_SCAN_POSTPROCESS_PYTHON: /opt/fedeo-device-agent/.venv-opencv/bin/python FEDEO_SCAN_POSTPROCESS_PYTHON: /opt/fedeo-device-agent/.venv-opencv/bin/python
FEDEO_SCAN_POSTPROCESS_STRICT: "false" FEDEO_SCAN_POSTPROCESS_STRICT: "false"
volumes: volumes:

View File

@@ -0,0 +1,572 @@
{
"name": "@fedeo/device-agent",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@fedeo/device-agent",
"version": "0.1.0",
"bin": {
"fedeo-device-agent": "dist/main.js"
},
"devDependencies": {
"@types/node": "^24.3.0",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "24.13.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.5.tgz",
"integrity": "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/tsx": {
"version": "4.23.13",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz",
"integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
}
}
}

View File

@@ -12,6 +12,8 @@
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"dev": "tsx src/main.ts", "dev": "tsx src/main.ts",
"start": "node dist/main.js", "start": "node dist/main.js",
"test": "node --import tsx --test tests/*.test.ts",
"test:python": "python3 -m unittest discover -s tests -p '*_test.py'",
"setup:opencv": "sh scripts/setup-opencv.sh" "setup:opencv": "sh scripts/setup-opencv.sh"
}, },
"dependencies": {}, "dependencies": {},

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Assemble ordered scanner pages; OpenCV is optional, PDF creation is not."""
import argparse
import json
from pathlib import Path
from PIL import Image
def create_document(inputs, output, resolution, use_postprocess=False, profile="document", strict=False):
if not inputs:
raise ValueError("Keine Seiten vorhanden")
output = Path(output)
file_format = {".pdf": "PDF", ".png": "PNG", ".tiff": "TIFF"}.get(output.suffix.lower())
if not file_format:
raise ValueError("Unbekanntes Ausgabeformat")
if file_format != "PDF" and len(inputs) != 1:
raise ValueError("Mehrseitige Scans benötigen PDF als Ausgabeformat")
warnings = []
processor = None
if use_postprocess:
try:
from opencv_postprocess import postprocess
processor = postprocess
except ImportError:
if strict:
raise
warnings.append("OpenCV fehlt; alle Seiten wurden ohne Bildkorrektur übernommen.")
partial = output.with_name(output.name + ".partial")
try:
for index, input_path in enumerate(inputs):
input_path = Path(input_path)
image_path = input_path
corrected = input_path.with_name(input_path.stem + ".corrected.png")
if processor:
try:
processor(input_path, corrected, profile)
image_path = corrected
except Exception:
if strict:
raise
warnings.append(f"Seite {index + 1}: Bildkorrektur fehlgeschlagen; Rohscan übernommen.")
# Only one decoded page is kept in memory, even for large duplex stacks.
with Image.open(image_path) as image:
with image.convert("RGB") as rgb:
if file_format == "PDF":
rgb.save(partial, "PDF", resolution=resolution, quality=90, append=index > 0)
else:
rgb.save(partial, file_format, dpi=(resolution, resolution))
corrected.unlink(missing_ok=True)
partial.replace(output)
finally:
partial.unlink(missing_ok=True)
return {"pageCount": len(inputs), "warnings": warnings}
def main():
parser = argparse.ArgumentParser(description="Scan-Seiten zu einer Datei zusammenführen")
parser.add_argument("--inputs", nargs="+", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--resolution", type=float, required=True)
parser.add_argument("--profile", choices=["document", "receipt", "raw"], default="document")
parser.add_argument("--postprocess", action="store_true")
parser.add_argument("--strict", action="store_true")
args = parser.parse_args()
print(json.dumps(create_document(args.inputs, args.output, args.resolution, args.postprocess, args.profile, args.strict)))
if __name__ == "__main__":
main()

View File

@@ -1,4 +1,4 @@
import { readFile } from "node:fs/promises" import { readFile, stat } from "node:fs/promises"
import { basename } from "node:path" import { basename } from "node:path"
import { AgentConfig, AgentHeartbeat, NextScanJobResponse, ScanResult } from "./types.js" import { AgentConfig, AgentHeartbeat, NextScanJobResponse, ScanResult } from "./types.js"
@@ -16,10 +16,11 @@ export class FedeoApi {
} }
} }
private async request<T>(path: string, init: RequestInit = {}): Promise<T> { private async request<T>(path: string, init: RequestInit = {}, timeoutMs = 30_000): Promise<T> {
const response = await fetch(this.url(path), { const response = await fetch(this.url(path), {
...init, ...init,
headers: this.headers(init.headers), headers: this.headers(init.headers),
signal: AbortSignal.timeout(timeoutMs),
}) })
if (!response.ok) { if (!response.ok) {
@@ -51,6 +52,9 @@ export class FedeoApi {
} }
async uploadScan(jobId: string, result: ScanResult) { async uploadScan(jobId: string, result: ScanResult) {
if ((await stat(result.path)).size > 100 * 1024 * 1024) {
throw new Error("Die Scan-Datei überschreitet das Upload-Limit von 100 MiB. Bitte den Stapel aufteilen oder die Auflösung reduzieren.")
}
const form = new FormData() const form = new FormData()
const fileBuffer = await readFile(result.path) const fileBuffer = await readFile(result.path)
const file = new File([fileBuffer], result.filename || basename(result.path), { const file = new File([fileBuffer], result.filename || basename(result.path), {
@@ -62,6 +66,6 @@ export class FedeoApi {
return this.request(`/instance-agent/scan-jobs/${jobId}/upload`, { return this.request(`/instance-agent/scan-jobs/${jobId}/upload`, {
method: "POST", method: "POST",
body: form, body: form,
}) }, 120_000)
} }
} }

View File

@@ -8,7 +8,7 @@ export type CommandResult = {
export const commandExists = (command: string) => export const commandExists = (command: string) =>
new Promise<boolean>((resolve) => { new Promise<boolean>((resolve) => {
const child = spawn("sh", ["-lc", `command -v ${command}`]) const child = spawn("sh", ["-c", 'command -v "$1" >/dev/null 2>&1', "--", command])
child.on("error", () => resolve(false)) child.on("error", () => resolve(false))
child.on("close", (code) => resolve(code === 0)) child.on("close", (code) => resolve(code === 0))
}) })
@@ -16,33 +16,50 @@ export const commandExists = (command: string) =>
export const runCommand = ( export const runCommand = (
command: string, command: string,
args: string[], args: string[],
options: { timeoutMs?: number } = {} options: { timeoutMs?: number; cwd?: string } = {}
) => ) =>
new Promise<CommandResult>((resolve, reject) => { new Promise<CommandResult>((resolve, reject) => {
const child = spawn(command, args, { const child = spawn(command, args, {
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
cwd: options.cwd,
}) })
const stdout: Buffer[] = [] const stdout: Buffer[] = []
const stderr: Buffer[] = [] const stderr: Buffer[] = []
let timedOut = false
let killTimeout: ReturnType<typeof setTimeout> | undefined
const timeout = options.timeoutMs const timeout = options.timeoutMs
? setTimeout(() => { ? setTimeout(() => {
timedOut = true
child.kill("SIGTERM") child.kill("SIGTERM")
reject(new Error(`${command} wurde nach ${options.timeoutMs} ms beendet`)) killTimeout = setTimeout(() => child.kill("SIGKILL"), 2000)
}, options.timeoutMs) }, options.timeoutMs)
: null : null
child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk))) child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)))
child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk))) child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)))
child.on("error", reject) child.on("error", (error) => {
child.on("close", (code) => {
if (timeout) clearTimeout(timeout) if (timeout) clearTimeout(timeout)
if (killTimeout) clearTimeout(killTimeout)
reject(error)
})
child.on("close", (code, signal) => {
if (timeout) clearTimeout(timeout)
if (killTimeout) clearTimeout(killTimeout)
if (timedOut) {
reject(new Error(`${command} wurde nach ${options.timeoutMs} ms beendet`))
return
}
if (signal) {
reject(new Error(`${command} wurde durch ${signal} beendet`))
return
}
resolve({ resolve({
stdout: Buffer.concat(stdout).toString("utf8"), stdout: Buffer.concat(stdout).toString("utf8"),
stderr: Buffer.concat(stderr).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8"),
code: code ?? 0, code: code ?? 1,
}) })
}) })
}) })

View File

@@ -60,6 +60,7 @@ export const loadConfig = (): AgentConfig => {
scanResolution: numberFromEnv(process.env.FEDEO_SCAN_RESOLUTION, 300), scanResolution: numberFromEnv(process.env.FEDEO_SCAN_RESOLUTION, 300),
scanMode: optional(process.env.FEDEO_SCAN_MODE) || "Color", scanMode: optional(process.env.FEDEO_SCAN_MODE) || "Color",
scanSource: optional(process.env.FEDEO_SCAN_SOURCE), scanSource: optional(process.env.FEDEO_SCAN_SOURCE),
scanTimeoutSeconds: numberFromEnv(process.env.FEDEO_SCAN_TIMEOUT_SECONDS, 900),
scanPostprocess: booleanFromEnv(process.env.FEDEO_SCAN_POSTPROCESS, false), scanPostprocess: booleanFromEnv(process.env.FEDEO_SCAN_POSTPROCESS, false),
postprocessProfile: postprocessProfileFromEnv(process.env.FEDEO_SCAN_POSTPROCESS_PROFILE), postprocessProfile: postprocessProfileFromEnv(process.env.FEDEO_SCAN_POSTPROCESS_PROFILE),
postprocessPython: optional(process.env.FEDEO_SCAN_POSTPROCESS_PYTHON) || defaultPostprocessPython(), postprocessPython: optional(process.env.FEDEO_SCAN_POSTPROCESS_PYTHON) || defaultPostprocessPython(),

View File

@@ -1,10 +1,13 @@
#!/usr/bin/env node #!/usr/bin/env node
import os from "node:os" import os from "node:os"
import path from "node:path"
import { rm } from "node:fs/promises"
import { FedeoApi } from "./api.js" import { FedeoApi } from "./api.js"
import { loadConfig } from "./config.js" import { loadConfig } from "./config.js"
import { log } from "./logger.js" import { log } from "./logger.js"
import { listPrinters } from "./print/cups.js" import { listPrinters } from "./print/cups.js"
import { hasSane, listScanners, runScan } from "./scan/sane.js" import { hasSane, listScanners, runScan } from "./scan/sane.js"
import { AgentHeartbeat } from "./types.js"
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
@@ -25,11 +28,14 @@ const main = async () => {
while (true) { while (true) {
try { try {
const scannerNames = await listScanners() const scannerNames = await listScanners().catch((error) => {
const printerNames = await listPrinters() log.warn("Scanner-Erkennung fehlgeschlagen", stringifyError(error))
return []
})
const printerNames = await listPrinters().catch(() => [])
const scanAvailable = await hasSane() const scanAvailable = await hasSane()
const heartbeat = await api.heartbeat({ const heartbeatPayload: AgentHeartbeat = {
capabilities: { capabilities: {
scan: scanAvailable, scan: scanAvailable,
print: printerNames.length > 0, print: printerNames.length > 0,
@@ -44,7 +50,8 @@ const main = async () => {
node: process.version, node: process.version,
uptimeSeconds: Math.round(os.uptime()), uptimeSeconds: Math.round(os.uptime()),
}, },
}) }
const heartbeat = await api.heartbeat(heartbeatPayload)
if (heartbeat.pendingScanJobs > 0) { if (heartbeat.pendingScanJobs > 0) {
log.info(`${heartbeat.pendingScanJobs} Scan-Auftrag/Aufträge warten`) log.info(`${heartbeat.pendingScanJobs} Scan-Auftrag/Aufträge warten`)
@@ -62,14 +69,44 @@ const main = async () => {
scannerName: next.job.scannerName || config.scannerName || "default", scannerName: next.job.scannerName || config.scannerName || "default",
}) })
// Keep online status fresh during long scans without probing an occupied USB scanner.
let heartbeatRunning = false
const heartbeatTimer = setInterval(async () => {
if (heartbeatRunning) return
heartbeatRunning = true
try {
await api.heartbeat(heartbeatPayload)
} catch (error) {
log.warn("Heartbeat während Scan fehlgeschlagen", stringifyError(error))
} finally {
heartbeatRunning = false
}
}, 30_000)
try { try {
await api.updateScanJobStatus(next.job.id, "running") await api.updateScanJobStatus(next.job.id, "running")
const scanResult = await runScan(config, next.job) const jobId = next.job.id
await api.uploadScan(next.job.id, scanResult) const reportProgress = async (message: string) => {
await api.updateScanJobStatus(jobId, "running", message).catch((error) => {
log.warn("Scan-Status konnte nicht übertragen werden", stringifyError(error))
})
}
const scanResult = await runScan(config, next.job, reportProgress)
await reportProgress(`${scanResult.pageCount} Seiten gescannt. ${scanResult.warnings.join(" ")}`.trim())
try {
await api.uploadScan(jobId, scanResult)
} catch (error) {
throw new Error(`${stringifyError(error)} Scan-Datei bleibt zur Wiederherstellung unter ${scanResult.path}.`)
}
await rm(path.dirname(scanResult.path), { recursive: true, force: true }).catch((error) => {
log.warn("Scan gespeichert; temporäre Dateien konnten nicht entfernt werden", stringifyError(error))
})
log.info("Scan-Auftrag abgeschlossen", { log.info("Scan-Auftrag abgeschlossen", {
jobId: next.job.id, jobId: next.job.id,
file: scanResult.filename, file: scanResult.filename,
pages: scanResult.pageCount,
}) })
} catch (error) { } catch (error) {
const message = stringifyError(error) const message = stringifyError(error)
@@ -79,6 +116,8 @@ const main = async () => {
}) })
await api.updateScanJobStatus(next.job.id, "failed", message) await api.updateScanJobStatus(next.job.id, "failed", message)
} finally {
clearInterval(heartbeatTimer)
} }
} catch (error) { } catch (error) {
log.error("Agent-Schleife fehlgeschlagen", stringifyError(error)) log.error("Agent-Schleife fehlgeschlagen", stringifyError(error))

View File

@@ -1,66 +1,45 @@
import path from "node:path" import path from "node:path"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
import { AgentConfig, ScanResult } from "../types.js" import { AgentConfig, ScanResult } from "../types.js"
import { commandExists, runCommand } from "../commands.js" import { runCommand } from "../commands.js"
const currentFile = fileURLToPath(import.meta.url) const agentRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..")
const agentRoot = path.resolve(path.dirname(currentFile), "../..") const documentScript = path.join(agentRoot, "scripts/scan_document.py")
const postprocessScript = path.join(agentRoot, "scripts/opencv_postprocess.py")
const extensionMimeTypes: Record<string, string> = { export const checkScanRuntime = async (config: AgentConfig, postprocess: boolean) => {
".pdf": "application/pdf", const imports = postprocess && config.postprocessStrict ? "import PIL, cv2, numpy" : "import PIL"
".png": "image/png", try {
".tif": "image/tiff", const result = await runCommand(config.postprocessPython, ["-c", imports], { timeoutMs: 10_000 })
".tiff": "image/tiff", if (result.code === 0) return
".jpg": "image/jpeg", } catch {
".jpeg": "image/jpeg", // Report missing runtimes before the scanner consumes any paper.
}
throw new Error("Python/Pillow (bei strikter Nachbearbeitung zusätzlich OpenCV) fehlt. Bitte npm run setup:opencv ausführen oder python3-pil installieren.")
} }
const ensureOutputExtension = (filename: string, format: AgentConfig["scanFormat"]) => { export const createScanDocument = async (
const ext = path.extname(filename)
if (ext) return filename
return `${filename}.${format}`
}
export const hasOpenCvPostprocessRuntime = async (config: AgentConfig) => {
if (!await commandExists(config.postprocessPython)) return false
const result = await runCommand(config.postprocessPython, [
"-c",
"import cv2, PIL, numpy",
], { timeoutMs: 10_000 })
return result.code === 0
}
export const postprocessScan = async (
config: AgentConfig, config: AgentConfig,
inputPath: string, pages: string[],
outputFilename: string, outputPath: string,
outputFormat: AgentConfig["scanFormat"], resolution: number,
postprocess: boolean,
profile: AgentConfig["postprocessProfile"] profile: AgentConfig["postprocessProfile"]
): Promise<ScanResult> => { ): Promise<ScanResult> => {
const filename = ensureOutputExtension(outputFilename, outputFormat) const args = [documentScript, "--output", outputPath, "--resolution", String(resolution), "--profile", profile]
const outputPath = path.join(config.workDir, filename) if (postprocess) args.push("--postprocess")
if (config.postprocessStrict) args.push("--strict")
args.push("--inputs", ...pages)
const result = await runCommand(config.postprocessPython, [ const result = await runCommand(config.postprocessPython, args, {
postprocessScript, timeoutMs: config.scanTimeoutSeconds * 1000,
"--input", })
inputPath, if (result.code !== 0) throw new Error(result.stderr || "Scan-Datei konnte nicht erstellt werden")
"--output",
outputPath,
"--profile",
profile,
], { timeoutMs: 5 * 60 * 1000 })
if (result.code !== 0) { const details = JSON.parse(result.stdout) as { pageCount: number; warnings: string[] }
throw new Error(result.stderr || `OpenCV-Nachbearbeitung wurde mit Code ${result.code} beendet`)
}
const extension = path.extname(outputPath).toLowerCase()
return { return {
path: outputPath, path: outputPath,
filename, filename: path.basename(outputPath),
mimeType: extensionMimeTypes[extension] || "application/octet-stream", mimeType: outputPath.endsWith(".pdf") ? "application/pdf" : outputPath.endsWith(".png") ? "image/png" : "image/tiff",
...details,
} }
} }

View File

@@ -1,149 +1,118 @@
import { mkdirSync } from "node:fs" import { mkdir, mkdtemp, readdir, stat } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { AgentConfig, ScanJob, ScanResult } from "../types.js" import { AgentConfig, ScanJob, ScanResult } from "../types.js"
import { commandExists, runCommand } from "../commands.js" import { commandExists, runCommand } from "../commands.js"
import { hasOpenCvPostprocessRuntime, postprocessScan } from "./postprocess.js" import { checkScanRuntime, createScanDocument } from "./postprocess.js"
import { log } from "../logger.js"
const mimeTypes = { const stringSetting = (settings: Record<string, unknown>, key: string) => {
pdf: "application/pdf", const value = settings[key]
png: "image/png",
tiff: "image/tiff",
}
const stringSetting = (settings: Record<string, unknown> | undefined, key: string) => {
const value = settings?.[key]
return typeof value === "string" && value.trim() ? value.trim() : undefined return typeof value === "string" && value.trim() ? value.trim() : undefined
} }
const numberSetting = (settings: Record<string, unknown> | undefined, key: string) => { const booleanSetting = (settings: Record<string, unknown>, key: string, fallback: boolean) => {
const value = settings?.[key] const value = settings[key]
if (typeof value === "number" && Number.isFinite(value)) return value
if (typeof value === "string" && value.trim()) {
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
return undefined
}
const booleanSetting = (settings: Record<string, unknown> | undefined, key: string, fallback: boolean) => {
const value = settings?.[key]
if (typeof value === "boolean") return value if (typeof value === "boolean") return value
if (typeof value === "string") return ["1", "true", "yes", "ja", "on"].includes(value.trim().toLowerCase()) if (typeof value === "string") return ["1", "true", "yes", "ja", "on"].includes(value.trim().toLowerCase())
return fallback return fallback
} }
const profileSetting = (
settings: Record<string, unknown> | undefined,
fallback: AgentConfig["postprocessProfile"]
): AgentConfig["postprocessProfile"] => {
const value = settings?.postprocessProfile
if (value === "document" || value === "receipt" || value === "raw") return value
return fallback
}
const ensureFilenameExtension = (filename: string, format: AgentConfig["scanFormat"]) => {
const ext = path.extname(filename)
if (!ext) return `${filename}.${format}`
const expectedExt = `.${format}`
if (ext.toLowerCase() === expectedExt) return filename
return `${filename.slice(0, -ext.length)}${expectedExt}`
}
const fallbackRawResult = (scanOutputPath: string, jobId: string): ScanResult => ({
path: scanOutputPath,
filename: `${jobId}.raw.png`,
mimeType: "image/png",
})
export const hasSane = () => commandExists("scanimage") export const hasSane = () => commandExists("scanimage")
export const listScanners = async () => { export const listScanners = async () => {
if (!await hasSane()) return [] if (!await hasSane()) return []
const result = await runCommand("scanimage", ["-L"], { timeoutMs: 10_000 }) const result = await runCommand("scanimage", ["-L"], { timeoutMs: 10_000 })
if (result.code !== 0) return [] if (result.code !== 0) return []
return result.stdout.split(/\r?\n/)
return result.stdout .map((line) => line.trim().match(/^device `([^']+)'/)?.[1])
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.startsWith("device `"))
.map((line) => line.match(/device `([^']+)'/)?.[1])
.filter((device): device is string => Boolean(device)) .filter((device): device is string => Boolean(device))
} }
export const runScan = async (config: AgentConfig, job: ScanJob): Promise<ScanResult> => { export const runScan = async (
if (!await hasSane()) { config: AgentConfig,
throw new Error("scanimage ist nicht installiert oder nicht im PATH") job: ScanJob,
} reportProgress: (message: string) => Promise<void> = async () => {}
): Promise<ScanResult> => {
mkdirSync(config.workDir, { recursive: true }) if (!await hasSane()) throw new Error("scanimage ist nicht installiert oder nicht im PATH")
const settings = job.settings || {} const settings = job.settings || {}
const format = stringSetting(settings, "format") as AgentConfig["scanFormat"] | undefined || config.scanFormat const format = stringSetting(settings, "format") || config.scanFormat
const resolution = numberSetting(settings, "resolution") || config.scanResolution if (!["pdf", "png", "tiff"].includes(format)) throw new Error("Scan-Format muss PDF, PNG oder TIFF sein")
const resolution = Number(settings.resolution ?? config.scanResolution)
if (!Number.isFinite(resolution) || resolution < 75 || resolution > 1200) {
throw new Error("Die Scan-Auflösung muss zwischen 75 und 1200 dpi liegen")
}
const mode = stringSetting(settings, "mode") || config.scanMode const mode = stringSetting(settings, "mode") || config.scanMode
const source = stringSetting(settings, "source") || config.scanSource const source = stringSetting(settings, "source") || config.scanSource
const scannerName = job.scannerName || config.scannerName const scannerName = job.scannerName || config.scannerName
const filename = ensureFilenameExtension(job.requestedFilename || `${job.id}.${format}`, format) const paperSize = stringSetting(settings, "paperSize") || "auto"
const outputPath = path.join(config.workDir, filename) if (!["auto", "A4", "Letter"].includes(paperSize)) throw new Error("Unbekanntes Papierformat")
const shouldPostprocess = booleanSetting(settings, "postprocess", config.scanPostprocess) const batch = Boolean(source && /adf|document feeder/i.test(source))
const postprocessProfile = profileSetting(settings, config.postprocessProfile) if (batch && format !== "pdf") throw new Error("Für Einzug- und Duplex-Scans bitte PDF als Format wählen")
const scanFormat = shouldPostprocess ? "png" : format const postprocess = booleanSetting(settings, "postprocess", config.scanPostprocess)
const scanOutputPath = shouldPostprocess const profile = stringSetting(settings, "postprocessProfile") || config.postprocessProfile
? path.join(config.workDir, `${job.id}.raw.png`) if (!["document", "receipt", "raw"].includes(profile)) throw new Error("Unbekanntes Nachbearbeitungsprofil")
: outputPath await checkScanRuntime(config, postprocess)
const args = [ // Each physical scan has its own directory. A filename cannot escape it or overwrite another job.
"--format", await mkdir(config.workDir, { recursive: true, mode: 0o700 })
scanFormat, const jobDir = await mkdtemp(path.join(path.resolve(config.workDir), "scan-"))
"--resolution", const requested = path.basename((job.requestedFilename || "scan").replace(/\\/g, "/"))
String(resolution), const stem = requested.replace(/\.[^.]*$/, "").replace(/[\x00-\x1f]/g, "").trim() || "scan"
"--mode", const outputPath = path.join(jobDir, `${stem}.${format}`)
mode,
"--output-file",
scanOutputPath,
]
if (source) args.push("--source", source) try {
if (scannerName) args.push("--device-name", scannerName) // Device/source come first: changing source can change the remaining SANE options.
const args: string[] = []
if (scannerName) args.push("--device-name", scannerName)
if (source) args.push("--source", source)
args.push("--mode", mode, "--resolution", String(resolution), "--format=png")
if (paperSize !== "auto") {
const [width, height] = paperSize === "A4" ? [210, 297] : [215.9, 279.4]
if (scannerName?.startsWith("fujitsu:")) {
args.push("--page-width", String(width), "--page-height", String(height))
}
args.push("-l", "0", "-t", "0", "-x", String(width), "-y", String(height))
}
if (batch) {
// Relative pattern avoids interpreting '%' in a user-configured work directory.
args.push("--batch=page-%06d.png", "--batch-start=1", "--batch-increment=1")
} else {
args.push("--output-file", "page-000001.png")
}
await reportProgress(batch ? "Dokumente werden eingezogen und gescannt..." : "Seite wird gescannt...")
const result = await runCommand("scanimage", args, {
cwd: jobDir,
timeoutMs: config.scanTimeoutSeconds * 1000,
})
const result = await runCommand("scanimage", args, { timeoutMs: 5 * 60 * 1000 }) // SANE_STATUS_NO_DOCS (7) is the expected end of an unlimited ADF batch.
// Never turn paper jams, I/O errors or canceled scans into a successful partial upload.
if (result.code !== 0) { if (result.code !== 0 && !(batch && result.code === 7)) {
throw new Error(result.stderr || `scanimage wurde mit Code ${result.code} beendet`) throw new Error(result.stderr.trim() || `scanimage wurde mit Code ${result.code} beendet`)
} }
const names = (await readdir(jobDir))
if (shouldPostprocess) { .filter((name) => /^page-\d+\.png$/.test(name))
if (!await hasOpenCvPostprocessRuntime(config)) { .sort((a, b) => Number(a.match(/\d+/)![0]) - Number(b.match(/\d+/)![0]))
const message = "OpenCV-Nachbearbeitung ist aktiviert, aber python3 mit cv2, Pillow und numpy ist nicht verfügbar" const pages: string[] = []
if (config.postprocessStrict) throw new Error(message) for (const [index, name] of names.entries()) {
const page = path.join(jobDir, name)
log.warn(`${message}. Rohscan wird ohne Korrektur hochgeladen.`, { // Some scanimage versions leave a zero-byte file for the final NO_DOCS attempt.
jobId: job.id, if ((await stat(page)).size === 0) {
python: config.postprocessPython, if (batch && result.code === 7 && index === names.length - 1) continue
}) throw new Error(`Unvollständige Scan-Seite: ${name}`)
return fallbackRawResult(scanOutputPath, job.id) }
if (Number(name.match(/\d+/)![0]) !== index + 1) throw new Error("Im Scan fehlen Seiten")
pages.push(page)
}
if (!pages.length) throw new Error("Keine Seiten gescannt. Bitte Papier in den Einzug legen.")
if (source?.toLowerCase().includes("duplex") && pages.length % 2 !== 0) {
throw new Error("Der Duplex-Scan ist unvollständig: Zu einer Vorderseite fehlt die Rückseite.")
} }
try { await reportProgress(`${pages.length} Seiten gescannt. Datei wird erstellt...`)
return await postprocessScan(config, scanOutputPath, filename, format, postprocessProfile) return await createScanDocument(config, pages, outputPath, resolution, postprocess, profile as AgentConfig["postprocessProfile"])
} catch (error) { } catch (error) {
if (config.postprocessStrict) throw error const message = error instanceof Error ? error.message : String(error)
throw new Error(`${message} Rohscans bleiben zur Wiederherstellung in ${jobDir}.`)
log.warn("OpenCV-Nachbearbeitung fehlgeschlagen. Rohscan wird ohne Korrektur hochgeladen.", {
jobId: job.id,
error: error instanceof Error ? error.message : String(error),
})
return fallbackRawResult(scanOutputPath, job.id)
}
}
return {
path: outputPath,
filename,
mimeType: mimeTypes[format] || "application/octet-stream",
} }
} }

View File

@@ -9,6 +9,7 @@ export type AgentConfig = {
scanResolution: number scanResolution: number
scanMode: string scanMode: string
scanSource?: string scanSource?: string
scanTimeoutSeconds: number
scanPostprocess: boolean scanPostprocess: boolean
postprocessProfile: "document" | "receipt" | "raw" postprocessProfile: "document" | "receipt" | "raw"
postprocessPython: string postprocessPython: string
@@ -45,4 +46,6 @@ export type ScanResult = {
path: string path: string
filename: string filename: string
mimeType: string mimeType: string
pageCount: number
warnings: string[]
} }

View File

@@ -0,0 +1,154 @@
import assert from "node:assert/strict"
import test, { TestContext } from "node:test"
import os from "node:os"
import path from "node:path"
import { chmod, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"
import { runScan } from "../src/scan/sane.js"
import { commandExists, runCommand } from "../src/commands.js"
import { AgentConfig, ScanJob } from "../src/types.js"
const fakeScanner = `#!/usr/bin/env python3
import json, os, sys
from pathlib import Path
from PIL import Image
args = sys.argv[1:]
Path(os.environ['SCAN_TEST_CALL']).write_text(json.dumps(args))
batch = next((arg.split('=', 1)[1] for arg in args if arg.startswith('--batch=')), None)
single = args[args.index('--output-file') + 1] if '--output-file' in args else None
count = int(os.environ.get('SCAN_TEST_PAGES', '4')) if batch else 1
for index in range(1, count + 1):
target = batch % index if batch else single
Image.new('RGB', (100 + index, 200 + index), (index * 10, 20, 30)).save(target)
if batch and os.environ.get('SCAN_TEST_EMPTY_FINAL') == '1':
Path(batch % (count + 1)).touch()
if os.environ.get('SCAN_TEST_CORRUPT') == '1':
Path(batch % 2 if batch else single).write_bytes(b'not a PNG')
if os.environ.get('SCAN_TEST_EMPTY_MIDDLE') == '1':
Path(batch % 2).write_bytes(b'')
sys.stderr.write('simulated scanner status')
sys.exit(int(os.environ.get('SCAN_TEST_EXIT', '7' if batch else '0')))
`
const setup = async (t: TestContext) => {
const dir = await mkdtemp(path.join(os.tmpdir(), "fedeo-scan-test-"))
const oldEnv = { ...process.env }
t.after(async () => {
for (const key of Object.keys(process.env)) if (!(key in oldEnv)) delete process.env[key]
Object.assign(process.env, oldEnv)
await rm(dir, { recursive: true, force: true })
})
await writeFile(path.join(dir, "scanimage"), fakeScanner)
await chmod(path.join(dir, "scanimage"), 0o755)
process.env.PATH = `${dir}:${process.env.PATH}`
process.env.SCAN_TEST_CALL = path.join(dir, "call.json")
const config: AgentConfig = {
fedeoUrl: "https://example.invalid", agentToken: "test", pollSeconds: 5,
workDir: path.join(dir, "scans with 100% space"), scannerName: "fujitsu:ScanSnap iX1600:test",
scanFormat: "pdf", scanResolution: 300, scanMode: "Color", scanSource: "ADF Duplex",
scanTimeoutSeconds: 10, scanPostprocess: false, postprocessProfile: "document",
postprocessPython: "python3", postprocessStrict: false,
}
const job: ScanJob = { id: "test-job", tenantId: 1, agentId: "test-agent", status: "running", settings: { paperSize: "A4" } }
return { config, job, dir, args: async () => JSON.parse(await readFile(process.env.SCAN_TEST_CALL!, "utf8")) as string[] }
}
test("duplex batch saves all pages as PDF, accepts NO_DOCS and passes A4 geometry", async (t) => {
const { config, job, args } = await setup(t)
process.env.SCAN_TEST_EMPTY_FINAL = "1"
const progress: string[] = []
const result = await runScan(config, job, async (message) => { progress.push(message) })
assert.equal(result.pageCount, 4)
assert.equal(result.mimeType, "application/pdf")
assert.equal((await readFile(result.path)).subarray(0, 5).toString(), "%PDF-")
assert.deepEqual(result.warnings, [])
const call = await args()
assert.ok(call.includes("--batch=page-%06d.png"))
assert.ok(!call.includes("--output-file"))
assert.ok(!call.includes("--batch-double"))
assert.equal(call[call.indexOf("--source") + 1], "ADF Duplex")
assert.equal(call[call.indexOf("--page-height") + 1], "297")
assert.equal(call[call.indexOf("-y") + 1], "297")
assert.ok(progress.at(-1)?.startsWith("4 Seiten"))
})
test("empty ADF reports missing paper instead of creating an empty PDF", async (t) => {
const { config, job } = await setup(t)
process.env.SCAN_TEST_PAGES = "0"
process.env.SCAN_TEST_EMPTY_FINAL = "1"
await assert.rejects(runScan(config, job), /Keine Seiten gescannt/)
})
test("paper jam after partial capture fails and keeps raw pages for recovery", async (t) => {
const { config, job } = await setup(t)
process.env.SCAN_TEST_EXIT = "6"
await assert.rejects(runScan(config, job), /simulated scanner status.*Rohscans/)
const [jobDir] = await readdir(config.workDir)
assert.equal((await readdir(path.join(config.workDir, jobDir))).filter((name) => name.endsWith(".png")).length, 4)
})
test("odd duplex page count cannot silently lose a back side", async (t) => {
const { config, job } = await setup(t)
process.env.SCAN_TEST_PAGES = "3"
await assert.rejects(runScan(config, job), /Rückseite/)
})
test("ADF simplex keeps odd page counts and scanimage success is accepted", async (t) => {
const { config, job } = await setup(t)
process.env.SCAN_TEST_PAGES = "3"
process.env.SCAN_TEST_EXIT = "0"
job.settings!.source = "ADF Front"
assert.equal((await runScan(config, job)).pageCount, 3)
})
test("corrupt or empty interior pages fail instead of disappearing from a successful PDF", async (t) => {
const { config, job } = await setup(t)
process.env.SCAN_TEST_CORRUPT = "1"
await assert.rejects(runScan(config, job), /cannot identify image file/)
delete process.env.SCAN_TEST_CORRUPT
process.env.SCAN_TEST_EMPTY_MIDDLE = "1"
await assert.rejects(runScan(config, job), /Unvollständige Scan-Seite/)
})
test("non-PDF feeder output and invalid resolution are rejected before feeding paper", async (t) => {
const { config, job, args } = await setup(t)
job.settings!.format = "png"
await assert.rejects(runScan(config, job), /PDF/)
delete job.settings!.format
job.settings!.resolution = -300
await assert.rejects(runScan(config, job), /Auflösung/)
await assert.rejects(args(), /ENOENT/)
})
test("missing image runtime is detected before feeding paper", async (t) => {
const { config, job, args } = await setup(t)
config.postprocessPython = "/not-installed/python"
await assert.rejects(runScan(config, job), /Python\/Pillow/)
await assert.rejects(args(), /ENOENT/)
})
test("single-page scans work without batch and requested paths cannot escape the job directory", async (t) => {
const { config, job, args } = await setup(t)
job.settings = { source: "Flatbed", format: "png" }
job.requestedFilename = "../../outside.pdf"
const first = await runScan(config, job)
const second = await runScan(config, job)
assert.equal(first.filename, "outside.png")
assert.equal(first.pageCount, 1)
assert.equal(first.mimeType, "image/png")
assert.ok(first.path.startsWith(`${config.workDir}${path.sep}`))
assert.notEqual(path.dirname(first.path), path.dirname(second.path))
assert.ok((await args()).includes("--output-file"))
assert.ok(!(await args()).some((arg) => arg.startsWith("--batch")))
})
test("a process terminated by a signal is never reported as a successful scan", async () => {
await assert.rejects(runCommand(process.execPath, ["-e", "process.kill(process.pid, 'SIGTERM')"]), /SIGTERM/)
})
test("a scan timeout terminates the child and reports failure", async () => {
await assert.rejects(runCommand(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { timeoutMs: 50 }), /beendet/)
})
test("command discovery treats executable paths as data", async () => {
assert.equal(await commandExists("python3; exit 0"), false)
})

View File

@@ -0,0 +1,93 @@
import sys
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from PIL import Image, PdfParser
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from scan_document import create_document
class ScanDocumentTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
def pages(self, count=4):
paths = []
for index in range(1, count + 1):
target = self.root / f"page-{index:06}.png"
# Distinct geometry identifies every front/back page and its order.
with Image.new("RGB", (100 + index, 200 + index), "white") as image:
image.save(target)
paths.append(target)
return paths
def assert_pdf(self, output, count, resolution=300):
with PdfParser.PdfParser(filename=str(output)) as pdf:
self.assertEqual(len(pdf.pages), count)
for index, reference in enumerate(pdf.pages, 1):
page = pdf.read_indirect(reference)
box = page[b"MediaBox"]
self.assertAlmostEqual(box[2], (100 + index) * 72 / resolution, places=4)
self.assertAlmostEqual(box[3], (200 + index) * 72 / resolution, places=4)
def test_twelve_pages_keep_front_back_order_blank_pages_and_scan_resolution(self):
output = self.root / "scan.pdf"
result = create_document(self.pages(12), output, 150)
self.assertEqual(result, {"pageCount": 12, "warnings": []})
self.assert_pdf(output, 12, 150)
def test_missing_opencv_still_creates_complete_pdf(self):
output = self.root / "scan.pdf"
with patch.dict(sys.modules, {"opencv_postprocess": None}):
result = create_document(self.pages(), output, 300, use_postprocess=True)
self.assertEqual(len(result["warnings"]), 1)
self.assert_pdf(output, 4)
def test_failed_correction_falls_back_per_page_without_losing_sides(self):
def correct(source, target, profile):
if source.name.endswith("000002.png"):
raise RuntimeError("correction failed")
target.write_bytes(source.read_bytes())
output = self.root / "scan.pdf"
with patch.dict(sys.modules, {"opencv_postprocess": SimpleNamespace(postprocess=correct)}):
result = create_document(self.pages(), output, 300, use_postprocess=True)
self.assertEqual(len(result["warnings"]), 1)
self.assertIn("Seite 2", result["warnings"][0])
self.assert_pdf(output, 4)
def test_strict_mode_does_not_silently_skip_missing_correction(self):
output = self.root / "scan.pdf"
with patch.dict(sys.modules, {"opencv_postprocess": None}):
with self.assertRaises(ImportError):
create_document(self.pages(), output, 300, use_postprocess=True, strict=True)
self.assertFalse(output.exists())
def test_corrupt_page_never_publishes_partial_pdf(self):
pages = self.pages()
pages[2].write_bytes(b"corrupt")
output = self.root / "scan.pdf"
with self.assertRaises(OSError):
create_document(pages, output, 300)
self.assertFalse(output.exists())
self.assertFalse((self.root / "scan.pdf.partial").exists())
self.assertTrue(all(page.exists() for page in pages))
def test_single_page_png_and_tiff_remain_supported(self):
pages = self.pages(1)
for extension in ["png", "tiff"]:
output = self.root / f"scan.{extension}"
create_document(pages, output, 150)
with Image.open(output) as image:
self.assertEqual(image.size, (101, 201))
self.assertAlmostEqual(image.info["dpi"][0], 150, delta=0.1)
if __name__ == "__main__":
unittest.main()

View File

@@ -5,7 +5,6 @@
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM"], "lib": ["ES2022", "DOM"],
"types": ["node"], "types": ["node"],
"typeRoots": ["../../backend/node_modules/@types"],
"rootDir": "src", "rootDir": "src",
"outDir": "dist", "outDir": "dist",
"strict": true, "strict": true,

View File

@@ -1,7 +1,8 @@
import { FastifyInstance, FastifyRequest } from "fastify" import { FastifyInstance, FastifyRequest } from "fastify"
import multipart from "@fastify/multipart" import multipart from "@fastify/multipart"
import { createHash } from "node:crypto" import { createHash } from "node:crypto"
import { and, asc, eq, sql } from "drizzle-orm" import { basename } from "node:path"
import { and, asc, eq, inArray, sql } from "drizzle-orm"
import { instanceAgentScanJobs, instanceAgents } from "../../db/schema" import { instanceAgentScanJobs, instanceAgents } from "../../db/schema"
import { saveFile } from "../utils/files" import { saveFile } from "../utils/files"
@@ -178,11 +179,12 @@ export default async function instanceAgentGatewayRoutes(server: FastifyInstance
}) })
.where(and( .where(and(
eq(instanceAgentScanJobs.id, req.params.id), eq(instanceAgentScanJobs.id, req.params.id),
eq(instanceAgentScanJobs.agentId, agent.id) eq(instanceAgentScanJobs.agentId, agent.id),
inArray(instanceAgentScanJobs.status, ["pending", "running"])
)) ))
.returning() .returning()
if (!job) return reply.code(404).send({ error: "Scan job not found" }) if (!job) return reply.code(409).send({ error: "Scan job is no longer active or does not exist" })
return { job } return { job }
}) })
@@ -209,7 +211,8 @@ export default async function instanceAgentGatewayRoutes(server: FastifyInstance
if (!data?.file) return reply.code(400).send({ error: "No file uploaded" }) if (!data?.file) return reply.code(400).send({ error: "No file uploaded" })
const fileBuffer = await data.toBuffer() const fileBuffer = await data.toBuffer()
const filename = job.requestedFilename || data.filename || `${job.id}.pdf` // The agent normalizes the extension to the actual output format.
const filename = basename(data.filename || job.requestedFilename || `${job.id}.pdf`)
const createdFile = await saveFile( const createdFile = await saveFile(
server, server,

View File

@@ -0,0 +1,89 @@
import assert from "node:assert/strict"
import test from "node:test"
import { createHash } from "node:crypto"
import { readFileSync } from "node:fs"
import { createRequire } from "node:module"
import path from "node:path"
import vm from "node:vm"
import ts from "typescript"
import Fastify from "fastify"
import { getTableColumns, getTableName } from "drizzle-orm"
import { PgDialect } from "drizzle-orm/pg-core"
// Load the real route with only file storage replaced; tests never contact S3 or a live database.
const routePath = path.resolve(__dirname, "../src/routes/instanceAgentGateway.ts")
const routeRequire = createRequire(routePath)
const compiled = ts.transpileModule(readFileSync(routePath, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true },
}).outputText
const fixture = async (status = "running") => {
const token = "test-agent-token"
const agent = { id: "agent", tokenHash: createHash("sha256").update(token).digest("hex"), active: true }
const job: any = { id: "job", agentId: "agent", tenantId: 7, requestedBy: "user", requestedFilename: "scan.png", target: { folder: "folder" }, status }
const saved: any[] = []
const rows: Record<string, any[]> = { instance_agents: [agent], instance_agent_scan_jobs: [job] }
const dialect = new PgDialect()
const matching = (table: any, condition: any) => {
const query = dialect.sqlToQuery(condition)
const columns = getTableColumns(table) as Record<string, any>
const clauses = [...query.sql.matchAll(/"\w+"\."(\w+)" (?:= \$(\d+)|in \(([^)]+)\))/g)]
assert.ok(clauses.length, query.sql)
return rows[getTableName(table)].filter(row => clauses.every(match => {
const key = Object.keys(columns).find(key => columns[key].name === match[1])!
if (match[2]) return row[key] === query.params[Number(match[2]) - 1]
return [...match[3].matchAll(/\$(\d+)/g)].some(parameter => row[key] === query.params[Number(parameter[1]) - 1])
}))
}
const db = {
select: () => ({ from: (table: any) => ({ where: (where: any) => ({ limit: async () => matching(table, where) }) }) }),
update: (table: any) => ({ set: (data: any) => ({ where: (where: any) => ({ returning: async () => matching(table, where).map(row => Object.assign(row, data)) }) }) }),
}
const exports: any = {}
vm.runInNewContext(compiled, {
exports,
require: (name: string) => name === "../utils/files"
? { saveFile: async (...args: any[]) => { saved.push(args); return { id: "saved-file" } } }
: routeRequire(name),
})
const app = Fastify()
app.decorate("db", db)
await app.register(exports.default)
return { app, job, saved, headers: { "x-agent-token": token } }
}
test("late failure status cannot overwrite an already completed scan", async (t) => {
const f = await fixture("completed")
t.after(() => f.app.close())
const response = await f.app.inject({ method: "POST", url: "/scan-jobs/job/status", headers: f.headers, payload: { status: "failed", message: "upload timeout" } })
assert.equal(response.statusCode, 409)
assert.equal(f.job.status, "completed")
})
test("running jobs accept progress and scanner failures", async (t) => {
const f = await fixture()
t.after(() => f.app.close())
for (const status of ["running", "failed"]) {
const response = await f.app.inject({ method: "POST", url: "/scan-jobs/job/status", headers: f.headers, payload: { status, message: "4 Seiten" } })
assert.equal(response.statusCode, 200)
assert.equal(f.job.status, status)
}
})
test("upload preserves the actual PDF filename, tenant and target folder", async (t) => {
const f = await fixture()
t.after(() => f.app.close())
const boundary = "fedeo-test-boundary"
const response = await f.app.inject({
method: "POST", url: "/scan-jobs/job/upload",
headers: { ...f.headers, "content-type": `multipart/form-data; boundary=${boundary}` },
payload: `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="scan.pdf"\r\nContent-Type: application/pdf\r\n\r\n%PDF-test\r\n--${boundary}--\r\n`,
})
assert.equal(response.statusCode, 200, response.body)
assert.equal(f.saved[0][1], 7)
assert.equal(f.saved[0][3].filename, "scan.pdf")
assert.equal(f.saved[0][3].contentType, "application/pdf")
assert.equal(f.saved[0][4], "folder")
assert.equal(f.job.status, "completed")
assert.equal(f.job.fileId, "saved-file")
})

View File

@@ -22,6 +22,8 @@ const agents = ref([])
const selectedAgentId = ref(null) const selectedAgentId = ref(null)
const currentJob = ref(null) const currentJob = ref(null)
const statusMessage = ref("") const statusMessage = ref("")
let disposed = false
onBeforeUnmount(() => { disposed = true })
const scanForm = reactive({ const scanForm = reactive({
filename: `scan-${new Date().toISOString().slice(0, 10)}.pdf`, filename: `scan-${new Date().toISOString().slice(0, 10)}.pdf`,
@@ -30,10 +32,27 @@ const scanForm = reactive({
resolution: 300, resolution: 300,
mode: "Color", mode: "Color",
source: "ADF Duplex", source: "ADF Duplex",
postprocess: true, paperSize: "A4",
postprocessProfile: "receipt" postprocess: false,
postprocessProfile: "document"
}) })
const isFeederScan = computed(() => /adf|document feeder/i.test(scanForm.source || ""))
const formatOptions = computed(() => isFeederScan.value
? [{ label: "PDF (alle Seiten)", value: "pdf" }]
: [{ label: "PDF", value: "pdf" }, { label: "PNG", value: "png" }, { label: "TIFF", value: "tiff" }])
watch(isFeederScan, (feeder) => { if (feeder) scanForm.format = "pdf" })
watch(() => scanForm.format, (format) => {
scanForm.filename = `${scanForm.filename.replace(/\.(pdf|png|tiff?)$/i, "")}.${format}`
})
const applyIx1600Preset = () => {
Object.assign(scanForm, {
format: "pdf", resolution: 300, mode: "Color", source: "ADF Duplex",
paperSize: "A4", postprocess: false, postprocessProfile: "document"
})
}
const activeAgents = computed(() => const activeAgents = computed(() =>
agents.value.filter((agent) => agent.active && agent.capabilities?.scan) agents.value.filter((agent) => agent.active && agent.capabilities?.scan)
) )
@@ -62,8 +81,10 @@ const applyAgentDefaults = (agent) => {
scanForm.resolution = Number(agent.scanDefaults?.resolution || 300) scanForm.resolution = Number(agent.scanDefaults?.resolution || 300)
scanForm.mode = agent.scanDefaults?.mode || "Color" scanForm.mode = agent.scanDefaults?.mode || "Color"
scanForm.source = agent.scanDefaults?.source || "ADF Duplex" scanForm.source = agent.scanDefaults?.source || "ADF Duplex"
scanForm.postprocess = agent.scanDefaults?.postprocess !== false scanForm.paperSize = agent.scanDefaults?.paperSize || "A4"
scanForm.postprocessProfile = agent.scanDefaults?.postprocessProfile || "receipt" scanForm.postprocess = Boolean(agent.scanDefaults?.postprocess)
scanForm.postprocessProfile = agent.scanDefaults?.postprocessProfile || "document"
if (isFeederScan.value) scanForm.format = "pdf"
if (!scanForm.filename || scanForm.filename.startsWith("scan-")) { if (!scanForm.filename || scanForm.filename.startsWith("scan-")) {
scanForm.filename = `scan-${new Date().toISOString().slice(0, 10)}.${scanForm.format || "pdf"}` scanForm.filename = `scan-${new Date().toISOString().slice(0, 10)}.${scanForm.format || "pdf"}`
@@ -94,10 +115,19 @@ const loadAgents = async () => {
} }
const waitForJob = async (jobId) => { const waitForJob = async (jobId) => {
for (let i = 0; i < 90; i++) { while (!disposed) {
await new Promise((resolve) => setTimeout(resolve, 2000)) await new Promise((resolve) => setTimeout(resolve, 2000))
if (disposed) return null
const response = await $api(`/api/scan-jobs/${jobId}`) let response
try {
response = await $api(`/api/scan-jobs/${jobId}`, { timeout: 15000, retry: 0 })
} catch (err) {
if ([401, 403, 404].includes(err?.statusCode || err?.response?.status)) throw err
statusMessage.value = "Verbindung unterbrochen. Der Scan läuft auf dem Rechner weiter; Status wird erneut abgefragt..."
continue
}
if (disposed) return null
const job = response?.job const job = response?.job
currentJob.value = job currentJob.value = job
@@ -106,16 +136,15 @@ const waitForJob = async (jobId) => {
throw new Error(job.agentMessage || "Scan-Auftrag wurde abgebrochen.") throw new Error(job.agentMessage || "Scan-Auftrag wurde abgebrochen.")
} }
statusMessage.value = job?.status === "running" statusMessage.value = job?.agentMessage || (job?.status === "running"
? "Scanner arbeitet..." ? "Scanner arbeitet..."
: "Warte auf den Agenten..." : "Warte auf den Agenten...")
} }
return null
throw new Error("Der Scan-Auftrag läuft länger als erwartet.")
} }
const startScan = async () => { const startScan = async () => {
if (!selectedAgentId.value) return if (!selectedAgentId.value || scanInProgress.value) return
scanInProgress.value = true scanInProgress.value = true
statusMessage.value = "Scan-Auftrag wird erstellt..." statusMessage.value = "Scan-Auftrag wird erstellt..."
@@ -133,6 +162,7 @@ const startScan = async () => {
resolution: Number(scanForm.resolution || 300), resolution: Number(scanForm.resolution || 300),
mode: scanForm.mode || "Color", mode: scanForm.mode || "Color",
source: scanForm.source || null, source: scanForm.source || null,
paperSize: scanForm.paperSize,
postprocess: scanForm.postprocess, postprocess: scanForm.postprocess,
postprocessProfile: scanForm.postprocessProfile postprocessProfile: scanForm.postprocessProfile
}, },
@@ -150,17 +180,19 @@ const startScan = async () => {
statusMessage.value = "Warte auf den Agenten..." statusMessage.value = "Warte auf den Agenten..."
await waitForJob(response.job.id) const finishedJob = await waitForJob(response.job.id)
if (!finishedJob || disposed) return
toast.add({ toast.add({
title: "Scan gespeichert", title: "Scan gespeichert",
description: props.scanData.folder ? "Die Datei wurde im aktuellen Ordner abgelegt." : "Die Datei wurde in Dateien abgelegt.", description: finishedJob.agentMessage || (props.scanData.folder ? "Die Datei wurde im aktuellen Ordner abgelegt." : "Die Datei wurde in Dateien abgelegt."),
color: "success" color: "success"
}) })
emit("scanFinished") emit("scanFinished")
modal.close() modal.close()
} catch (err) { } catch (err) {
if (disposed) return
toast.add({ toast.add({
title: "Scan fehlgeschlagen", title: "Scan fehlgeschlagen",
description: err?.data?.error || err?.message || "Unbekannter Fehler", description: err?.data?.error || err?.message || "Unbekannter Fehler",
@@ -246,6 +278,10 @@ loadAgents()
<UInput v-model="scanForm.filename" :disabled="scanInProgress" /> <UInput v-model="scanForm.filename" :disabled="scanInProgress" />
</UFormField> </UFormField>
<UButton color="neutral" variant="soft" :disabled="scanInProgress" @click="applyIx1600Preset">
ScanSnap iX1600: A4, Duplex, PDF
</UButton>
<div class="grid gap-3 sm:grid-cols-2"> <div class="grid gap-3 sm:grid-cols-2">
<UFormField label="Quelle"> <UFormField label="Quelle">
<UInput v-model="scanForm.source" placeholder="ADF Duplex" :disabled="scanInProgress" /> <UInput v-model="scanForm.source" placeholder="ADF Duplex" :disabled="scanInProgress" />
@@ -255,6 +291,17 @@ loadAgents()
</UFormField> </UFormField>
</div> </div>
<UFormField label="Papierformat">
<USelectMenu v-model="scanForm.paperSize" :items="[
{ label: 'A4', value: 'A4' }, { label: 'Letter', value: 'Letter' },
{ label: 'Scanner-Vorgabe', value: 'auto' }
]" value-key="value" label-key="label" :disabled="scanInProgress" />
</UFormField>
<p v-if="isFeederScan" class="text-xs text-muted">
Der gesamte Stapel wird als ein PDF gespeichert. Bei Duplex bleiben Vorder- und Rückseiten einschließlich Leerseiten erhalten.
</p>
<div class="grid gap-3 sm:grid-cols-2"> <div class="grid gap-3 sm:grid-cols-2">
<UFormField label="Modus"> <UFormField label="Modus">
<UInput v-model="scanForm.mode" placeholder="Color" :disabled="scanInProgress" /> <UInput v-model="scanForm.mode" placeholder="Color" :disabled="scanInProgress" />
@@ -262,11 +309,7 @@ loadAgents()
<UFormField label="Format"> <UFormField label="Format">
<USelectMenu <USelectMenu
v-model="scanForm.format" v-model="scanForm.format"
:items="[ :items="formatOptions"
{ label: 'PDF', value: 'pdf' },
{ label: 'PNG', value: 'png' },
{ label: 'TIFF', value: 'tiff' }
]"
value-key="value" value-key="value"
label-key="label" label-key="label"
:disabled="scanInProgress" :disabled="scanInProgress"
@@ -307,7 +350,9 @@ loadAgents()
<template #footer> <template #footer>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<UButton color="neutral" variant="ghost" :disabled="scanInProgress" @click="modal.close()">Abbrechen</UButton> <UButton color="neutral" variant="ghost" @click="modal.close()">
{{ scanInProgress ? 'Im Hintergrund weiterlaufen lassen' : 'Schließen' }}
</UButton>
<UButton <UButton
icon="i-heroicons-document-magnifying-glass" icon="i-heroicons-document-magnifying-glass"
:loading="scanInProgress" :loading="scanInProgress"

View File

@@ -27,6 +27,7 @@ const editState = reactive({
resolution: 300, resolution: 300,
mode: "Color", mode: "Color",
source: "", source: "",
paperSize: "A4",
postprocess: false, postprocess: false,
postprocessProfile: "document", postprocessProfile: "document",
}) })
@@ -64,10 +65,18 @@ const applyAgentToForm = (agent: InstanceAgent | null) => {
editState.resolution = Number(agent.scanDefaults?.resolution || 300) editState.resolution = Number(agent.scanDefaults?.resolution || 300)
editState.mode = agent.scanDefaults?.mode || "Color" editState.mode = agent.scanDefaults?.mode || "Color"
editState.source = agent.scanDefaults?.source || "" editState.source = agent.scanDefaults?.source || ""
editState.paperSize = agent.scanDefaults?.paperSize || "A4"
editState.postprocess = Boolean(agent.scanDefaults?.postprocess) editState.postprocess = Boolean(agent.scanDefaults?.postprocess)
editState.postprocessProfile = agent.scanDefaults?.postprocessProfile || "document" editState.postprocessProfile = agent.scanDefaults?.postprocessProfile || "document"
} }
const applyIx1600Preset = () => {
Object.assign(editState, {
format: "pdf", resolution: 300, mode: "Color", source: "ADF Duplex",
paperSize: "A4", postprocess: false, postprocessProfile: "document"
})
}
watch(selectedAgent, (agent) => applyAgentToForm(agent), { immediate: true }) watch(selectedAgent, (agent) => applyAgentToForm(agent), { immediate: true })
const loadAgents = async () => { const loadAgents = async () => {
@@ -130,6 +139,10 @@ const createAgent = async () => {
const saveAgent = async () => { const saveAgent = async () => {
if (!selectedAgent.value) return if (!selectedAgent.value) return
if (/adf|document feeder/i.test(editState.source) && editState.format !== "pdf") {
toast.add({ title: "Für Einzug- und Duplex-Scans bitte PDF wählen", color: "error" })
return
}
saving.value = true saving.value = true
@@ -144,6 +157,7 @@ const saveAgent = async () => {
resolution: Number(editState.resolution || 300), resolution: Number(editState.resolution || 300),
mode: editState.mode, mode: editState.mode,
source: editState.source || null, source: editState.source || null,
paperSize: editState.paperSize,
postprocess: editState.postprocess, postprocess: editState.postprocess,
postprocessProfile: editState.postprocessProfile, postprocessProfile: editState.postprocessProfile,
}, },
@@ -315,6 +329,10 @@ onMounted(async () => {
/> />
</UFormField> </UFormField>
<UButton color="neutral" variant="soft" @click="applyIx1600Preset">
ScanSnap iX1600: A4, Duplex, PDF
</UButton>
<div class="grid gap-3 sm:grid-cols-2"> <div class="grid gap-3 sm:grid-cols-2">
<UFormField label="Format"> <UFormField label="Format">
<USelectMenu <USelectMenu
@@ -342,6 +360,13 @@ onMounted(async () => {
</UFormField> </UFormField>
</div> </div>
<UFormField label="Papierformat">
<USelectMenu v-model="editState.paperSize" :items="[
{ label: 'A4', value: 'A4' }, { label: 'Letter', value: 'Letter' },
{ label: 'Scanner-Vorgabe', value: 'auto' }
]" value-key="value" label-key="label" />
</UFormField>
<div class="grid gap-3 sm:grid-cols-[auto_minmax(0,1fr)]"> <div class="grid gap-3 sm:grid-cols-[auto_minmax(0,1fr)]">
<UCheckbox v-model="editState.postprocess" label="OpenCV-Nachbearbeitung" /> <UCheckbox v-model="editState.postprocess" label="OpenCV-Nachbearbeitung" />
<UFormField label="Profil"> <UFormField label="Profil">

View File

@@ -0,0 +1,72 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const vue = require('vue')
async function setup(getJob) {
const notices = []
const requests = []
let unmount
const context = vm.createContext({
ref: vue.ref, reactive: vue.reactive, computed: vue.computed, watch: vue.watch,
onBeforeUnmount: handler => { unmount = handler },
defineProps: () => ({ scanData: { folder: 'folder-1', type: 'document' } }),
defineEmits: () => () => {},
useModal: () => ({ close() {} }),
useToast: () => ({ add: notice => notices.push(notice) }),
setTimeout: callback => queueMicrotask(callback),
useNuxtApp: () => ({ $api: async (url, options) => {
requests.push({ url, options })
if (url === '/api/instance-agents') return { agents: [{ id: 'agent', active: true, capabilities: { scan: true }, scannerNames: ['fujitsu:ScanSnap iX1600:test'] }] }
if (url === '/api/scan-jobs') return { job: { id: 'job' } }
return getJob()
} }),
})
const source = fs.readFileSync(`${__dirname}/../components/FileScanModal.vue`, 'utf8').split('<script setup>')[1].split('</script>')[0]
vm.runInContext(source, context)
await new Promise(setImmediate)
return { run: code => vm.runInContext(code, context), notices, requests, close: () => unmount() }
}
test('duplex scanning continues past the former three-minute polling limit', async () => {
let polls = 0
const ui = await setup(() => ({ job: { status: ++polls > 95 ? 'completed' : 'running', agentMessage: '4 Seiten gescannt.' } }))
await ui.run('startScan()')
assert.equal(polls, 96)
assert.equal(ui.notices[0].title, 'Scan gespeichert')
const body = ui.requests.find(request => request.url === '/api/scan-jobs').options.body
assert.equal(body.settings.source, 'ADF Duplex')
assert.equal(body.settings.paperSize, 'A4')
assert.equal(body.target.folder, 'folder-1')
})
test('temporary status connection failures do not create duplicate scans', async () => {
let polls = 0
const ui = await setup(() => {
if (++polls < 3) throw Error('offline')
return { job: { status: 'completed' } }
})
await ui.run('startScan()')
assert.equal(ui.requests.filter(request => request.url === '/api/scan-jobs').length, 1)
assert.equal(ui.notices[0].title, 'Scan gespeichert')
})
test('closing the modal stops polling without reporting that a background scan failed', async () => {
const ui = await setup(() => { throw Error('must not poll') })
ui.close()
assert.equal(await ui.run('waitForJob("job")'), null)
assert.equal(ui.notices.length, 0)
})
test('iX1600 preset resets image settings and normalizes the output filename to PDF', async () => {
const ui = await setup(() => ({}))
ui.run('scanForm.format="png"; scanForm.source="Flatbed"; scanForm.filename="document.png"; scanForm.postprocess=true')
await new Promise(setImmediate)
ui.run('applyIx1600Preset()')
await new Promise(setImmediate)
assert.equal(ui.run('scanForm.format'), 'pdf')
assert.equal(ui.run('scanForm.filename'), 'document.pdf')
assert.equal(ui.run('scanForm.postprocess'), false)
assert.equal(ui.run('scanForm.resolution'), 300)
})