3.0.0 #220
@@ -4,3 +4,5 @@ dist
|
||||
.env
|
||||
*.log
|
||||
*.tmp
|
||||
__pycache__
|
||||
tests
|
||||
|
||||
@@ -8,6 +8,7 @@ FEDEO_SCAN_FORMAT=pdf
|
||||
FEDEO_SCAN_RESOLUTION=300
|
||||
FEDEO_SCAN_MODE=Color
|
||||
FEDEO_SCAN_SOURCE=
|
||||
FEDEO_SCAN_TIMEOUT_SECONDS=900
|
||||
FEDEO_SCAN_POSTPROCESS=false
|
||||
FEDEO_SCAN_POSTPROCESS_PROFILE=document
|
||||
FEDEO_SCAN_POSTPROCESS_PYTHON=
|
||||
|
||||
1
agents/fedeo-device-agent/.gitignore
vendored
1
agents/fedeo-device-agent/.gitignore
vendored
@@ -4,3 +4,4 @@ node_modules
|
||||
.env
|
||||
*.log
|
||||
*.tmp
|
||||
__pycache__/
|
||||
|
||||
@@ -2,8 +2,8 @@ FROM node:20-bookworm-slim AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json tsconfig.json ./
|
||||
RUN npm install
|
||||
COPY package.json package-lock.json tsconfig.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
@@ -12,8 +12,8 @@ FROM node:20-bookworm-slim AS runtime
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV FEDEO_WORK_DIR=/work
|
||||
ENV FEDEO_SCAN_POSTPROCESS=true
|
||||
ENV FEDEO_SCAN_POSTPROCESS_PROFILE=receipt
|
||||
ENV FEDEO_SCAN_POSTPROCESS=false
|
||||
ENV FEDEO_SCAN_POSTPROCESS_PROFILE=document
|
||||
ENV FEDEO_SCAN_POSTPROCESS_PYTHON=/opt/fedeo-device-agent/.venv-opencv/bin/python
|
||||
ENV FEDEO_SCAN_POSTPROCESS_STRICT=false
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ brew install node sane-backends
|
||||
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:
|
||||
|
||||
```bash
|
||||
@@ -23,11 +25,52 @@ lpstat -p
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y nodejs npm sane-utils cups
|
||||
sudo apt install -y nodejs npm sane-utils python3-pil
|
||||
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
|
||||
|
||||
```bash
|
||||
@@ -49,7 +92,7 @@ Wenn `FEDEO_SCANNER_NAME` leer bleibt, verwendet `scanimage` den Standard-Scanne
|
||||
## Entwicklung
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
@@ -70,7 +113,7 @@ FEDEO_SCAN_POSTPROCESS_PYTHON=/pfad/zum/agent/.venv-opencv/bin/python
|
||||
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:
|
||||
|
||||
@@ -109,6 +152,17 @@ npm run build
|
||||
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
|
||||
|
||||
Der Agent nutzt:
|
||||
|
||||
@@ -9,8 +9,6 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
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_STRICT: "false"
|
||||
volumes:
|
||||
|
||||
572
agents/fedeo-device-agent/package-lock.json
generated
Normal file
572
agents/fedeo-device-agent/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "tsx src/main.ts",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {},
|
||||
|
||||
74
agents/fedeo-device-agent/scripts/scan_document.py
Normal file
74
agents/fedeo-device-agent/scripts/scan_document.py
Normal 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()
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { readFile, stat } from "node:fs/promises"
|
||||
import { basename } from "node:path"
|
||||
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), {
|
||||
...init,
|
||||
headers: this.headers(init.headers),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -51,6 +52,9 @@ export class FedeoApi {
|
||||
}
|
||||
|
||||
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 fileBuffer = await readFile(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`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
})
|
||||
}, 120_000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export type CommandResult = {
|
||||
|
||||
export const commandExists = (command: string) =>
|
||||
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("close", (code) => resolve(code === 0))
|
||||
})
|
||||
@@ -16,33 +16,50 @@ export const commandExists = (command: string) =>
|
||||
export const runCommand = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { timeoutMs?: number } = {}
|
||||
options: { timeoutMs?: number; cwd?: string } = {}
|
||||
) =>
|
||||
new Promise<CommandResult>((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
cwd: options.cwd,
|
||||
})
|
||||
|
||||
const stdout: Buffer[] = []
|
||||
const stderr: Buffer[] = []
|
||||
|
||||
let timedOut = false
|
||||
let killTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
const timeout = options.timeoutMs
|
||||
? setTimeout(() => {
|
||||
timedOut = true
|
||||
child.kill("SIGTERM")
|
||||
reject(new Error(`${command} wurde nach ${options.timeoutMs} ms beendet`))
|
||||
killTimeout = setTimeout(() => child.kill("SIGKILL"), 2000)
|
||||
}, options.timeoutMs)
|
||||
: null
|
||||
|
||||
child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)))
|
||||
child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)))
|
||||
child.on("error", reject)
|
||||
child.on("close", (code) => {
|
||||
child.on("error", (error) => {
|
||||
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({
|
||||
stdout: Buffer.concat(stdout).toString("utf8"),
|
||||
stderr: Buffer.concat(stderr).toString("utf8"),
|
||||
code: code ?? 0,
|
||||
code: code ?? 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -60,6 +60,7 @@ export const loadConfig = (): AgentConfig => {
|
||||
scanResolution: numberFromEnv(process.env.FEDEO_SCAN_RESOLUTION, 300),
|
||||
scanMode: optional(process.env.FEDEO_SCAN_MODE) || "Color",
|
||||
scanSource: optional(process.env.FEDEO_SCAN_SOURCE),
|
||||
scanTimeoutSeconds: numberFromEnv(process.env.FEDEO_SCAN_TIMEOUT_SECONDS, 900),
|
||||
scanPostprocess: booleanFromEnv(process.env.FEDEO_SCAN_POSTPROCESS, false),
|
||||
postprocessProfile: postprocessProfileFromEnv(process.env.FEDEO_SCAN_POSTPROCESS_PROFILE),
|
||||
postprocessPython: optional(process.env.FEDEO_SCAN_POSTPROCESS_PYTHON) || defaultPostprocessPython(),
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { FedeoApi } from "./api.js"
|
||||
import { loadConfig } from "./config.js"
|
||||
import { log } from "./logger.js"
|
||||
import { listPrinters } from "./print/cups.js"
|
||||
import { hasSane, listScanners, runScan } from "./scan/sane.js"
|
||||
import { AgentHeartbeat } from "./types.js"
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
@@ -25,11 +28,14 @@ const main = async () => {
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const scannerNames = await listScanners()
|
||||
const printerNames = await listPrinters()
|
||||
const scannerNames = await listScanners().catch((error) => {
|
||||
log.warn("Scanner-Erkennung fehlgeschlagen", stringifyError(error))
|
||||
return []
|
||||
})
|
||||
const printerNames = await listPrinters().catch(() => [])
|
||||
const scanAvailable = await hasSane()
|
||||
|
||||
const heartbeat = await api.heartbeat({
|
||||
const heartbeatPayload: AgentHeartbeat = {
|
||||
capabilities: {
|
||||
scan: scanAvailable,
|
||||
print: printerNames.length > 0,
|
||||
@@ -44,7 +50,8 @@ const main = async () => {
|
||||
node: process.version,
|
||||
uptimeSeconds: Math.round(os.uptime()),
|
||||
},
|
||||
})
|
||||
}
|
||||
const heartbeat = await api.heartbeat(heartbeatPayload)
|
||||
|
||||
if (heartbeat.pendingScanJobs > 0) {
|
||||
log.info(`${heartbeat.pendingScanJobs} Scan-Auftrag/Aufträge warten`)
|
||||
@@ -62,14 +69,44 @@ const main = async () => {
|
||||
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 {
|
||||
await api.updateScanJobStatus(next.job.id, "running")
|
||||
const scanResult = await runScan(config, next.job)
|
||||
await api.uploadScan(next.job.id, scanResult)
|
||||
const jobId = next.job.id
|
||||
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", {
|
||||
jobId: next.job.id,
|
||||
file: scanResult.filename,
|
||||
pages: scanResult.pageCount,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = stringifyError(error)
|
||||
@@ -79,6 +116,8 @@ const main = async () => {
|
||||
})
|
||||
|
||||
await api.updateScanJobStatus(next.job.id, "failed", message)
|
||||
} finally {
|
||||
clearInterval(heartbeatTimer)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Agent-Schleife fehlgeschlagen", stringifyError(error))
|
||||
|
||||
@@ -1,66 +1,45 @@
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
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(currentFile), "../..")
|
||||
const postprocessScript = path.join(agentRoot, "scripts/opencv_postprocess.py")
|
||||
const agentRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..")
|
||||
const documentScript = path.join(agentRoot, "scripts/scan_document.py")
|
||||
|
||||
const extensionMimeTypes: Record<string, string> = {
|
||||
".pdf": "application/pdf",
|
||||
".png": "image/png",
|
||||
".tif": "image/tiff",
|
||||
".tiff": "image/tiff",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
export const checkScanRuntime = async (config: AgentConfig, postprocess: boolean) => {
|
||||
const imports = postprocess && config.postprocessStrict ? "import PIL, cv2, numpy" : "import PIL"
|
||||
try {
|
||||
const result = await runCommand(config.postprocessPython, ["-c", imports], { timeoutMs: 10_000 })
|
||||
if (result.code === 0) return
|
||||
} catch {
|
||||
// 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"]) => {
|
||||
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 (
|
||||
export const createScanDocument = async (
|
||||
config: AgentConfig,
|
||||
inputPath: string,
|
||||
outputFilename: string,
|
||||
outputFormat: AgentConfig["scanFormat"],
|
||||
pages: string[],
|
||||
outputPath: string,
|
||||
resolution: number,
|
||||
postprocess: boolean,
|
||||
profile: AgentConfig["postprocessProfile"]
|
||||
): Promise<ScanResult> => {
|
||||
const filename = ensureOutputExtension(outputFilename, outputFormat)
|
||||
const outputPath = path.join(config.workDir, filename)
|
||||
const args = [documentScript, "--output", outputPath, "--resolution", String(resolution), "--profile", profile]
|
||||
if (postprocess) args.push("--postprocess")
|
||||
if (config.postprocessStrict) args.push("--strict")
|
||||
args.push("--inputs", ...pages)
|
||||
|
||||
const result = await runCommand(config.postprocessPython, [
|
||||
postprocessScript,
|
||||
"--input",
|
||||
inputPath,
|
||||
"--output",
|
||||
outputPath,
|
||||
"--profile",
|
||||
profile,
|
||||
], { timeoutMs: 5 * 60 * 1000 })
|
||||
const result = await runCommand(config.postprocessPython, args, {
|
||||
timeoutMs: config.scanTimeoutSeconds * 1000,
|
||||
})
|
||||
if (result.code !== 0) throw new Error(result.stderr || "Scan-Datei konnte nicht erstellt werden")
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || `OpenCV-Nachbearbeitung wurde mit Code ${result.code} beendet`)
|
||||
}
|
||||
|
||||
const extension = path.extname(outputPath).toLowerCase()
|
||||
const details = JSON.parse(result.stdout) as { pageCount: number; warnings: string[] }
|
||||
return {
|
||||
path: outputPath,
|
||||
filename,
|
||||
mimeType: extensionMimeTypes[extension] || "application/octet-stream",
|
||||
filename: path.basename(outputPath),
|
||||
mimeType: outputPath.endsWith(".pdf") ? "application/pdf" : outputPath.endsWith(".png") ? "image/png" : "image/tiff",
|
||||
...details,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,149 +1,118 @@
|
||||
import { mkdirSync } from "node:fs"
|
||||
import { mkdir, mkdtemp, readdir, stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { AgentConfig, ScanJob, ScanResult } from "../types.js"
|
||||
import { commandExists, runCommand } from "../commands.js"
|
||||
import { hasOpenCvPostprocessRuntime, postprocessScan } from "./postprocess.js"
|
||||
import { log } from "../logger.js"
|
||||
import { checkScanRuntime, createScanDocument } from "./postprocess.js"
|
||||
|
||||
const mimeTypes = {
|
||||
pdf: "application/pdf",
|
||||
png: "image/png",
|
||||
tiff: "image/tiff",
|
||||
}
|
||||
|
||||
const stringSetting = (settings: Record<string, unknown> | undefined, key: string) => {
|
||||
const value = settings?.[key]
|
||||
const stringSetting = (settings: Record<string, unknown>, key: string) => {
|
||||
const value = settings[key]
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined
|
||||
}
|
||||
|
||||
const numberSetting = (settings: Record<string, unknown> | undefined, key: string) => {
|
||||
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]
|
||||
const booleanSetting = (settings: Record<string, unknown>, key: string, fallback: boolean) => {
|
||||
const value = settings[key]
|
||||
if (typeof value === "boolean") return value
|
||||
if (typeof value === "string") return ["1", "true", "yes", "ja", "on"].includes(value.trim().toLowerCase())
|
||||
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 listScanners = async () => {
|
||||
if (!await hasSane()) return []
|
||||
|
||||
const result = await runCommand("scanimage", ["-L"], { timeoutMs: 10_000 })
|
||||
if (result.code !== 0) return []
|
||||
|
||||
return result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith("device `"))
|
||||
.map((line) => line.match(/device `([^']+)'/)?.[1])
|
||||
return result.stdout.split(/\r?\n/)
|
||||
.map((line) => line.trim().match(/^device `([^']+)'/)?.[1])
|
||||
.filter((device): device is string => Boolean(device))
|
||||
}
|
||||
|
||||
export const runScan = async (config: AgentConfig, job: ScanJob): Promise<ScanResult> => {
|
||||
if (!await hasSane()) {
|
||||
throw new Error("scanimage ist nicht installiert oder nicht im PATH")
|
||||
}
|
||||
|
||||
mkdirSync(config.workDir, { recursive: true })
|
||||
export const runScan = async (
|
||||
config: AgentConfig,
|
||||
job: ScanJob,
|
||||
reportProgress: (message: string) => Promise<void> = async () => {}
|
||||
): Promise<ScanResult> => {
|
||||
if (!await hasSane()) throw new Error("scanimage ist nicht installiert oder nicht im PATH")
|
||||
|
||||
const settings = job.settings || {}
|
||||
const format = stringSetting(settings, "format") as AgentConfig["scanFormat"] | undefined || config.scanFormat
|
||||
const resolution = numberSetting(settings, "resolution") || config.scanResolution
|
||||
const format = stringSetting(settings, "format") || config.scanFormat
|
||||
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 source = stringSetting(settings, "source") || config.scanSource
|
||||
const scannerName = job.scannerName || config.scannerName
|
||||
const filename = ensureFilenameExtension(job.requestedFilename || `${job.id}.${format}`, format)
|
||||
const outputPath = path.join(config.workDir, filename)
|
||||
const shouldPostprocess = booleanSetting(settings, "postprocess", config.scanPostprocess)
|
||||
const postprocessProfile = profileSetting(settings, config.postprocessProfile)
|
||||
const scanFormat = shouldPostprocess ? "png" : format
|
||||
const scanOutputPath = shouldPostprocess
|
||||
? path.join(config.workDir, `${job.id}.raw.png`)
|
||||
: outputPath
|
||||
const paperSize = stringSetting(settings, "paperSize") || "auto"
|
||||
if (!["auto", "A4", "Letter"].includes(paperSize)) throw new Error("Unbekanntes Papierformat")
|
||||
const batch = Boolean(source && /adf|document feeder/i.test(source))
|
||||
if (batch && format !== "pdf") throw new Error("Für Einzug- und Duplex-Scans bitte PDF als Format wählen")
|
||||
const postprocess = booleanSetting(settings, "postprocess", config.scanPostprocess)
|
||||
const profile = stringSetting(settings, "postprocessProfile") || config.postprocessProfile
|
||||
if (!["document", "receipt", "raw"].includes(profile)) throw new Error("Unbekanntes Nachbearbeitungsprofil")
|
||||
await checkScanRuntime(config, postprocess)
|
||||
|
||||
const args = [
|
||||
"--format",
|
||||
scanFormat,
|
||||
"--resolution",
|
||||
String(resolution),
|
||||
"--mode",
|
||||
mode,
|
||||
"--output-file",
|
||||
scanOutputPath,
|
||||
]
|
||||
// Each physical scan has its own directory. A filename cannot escape it or overwrite another job.
|
||||
await mkdir(config.workDir, { recursive: true, mode: 0o700 })
|
||||
const jobDir = await mkdtemp(path.join(path.resolve(config.workDir), "scan-"))
|
||||
const requested = path.basename((job.requestedFilename || "scan").replace(/\\/g, "/"))
|
||||
const stem = requested.replace(/\.[^.]*$/, "").replace(/[\x00-\x1f]/g, "").trim() || "scan"
|
||||
const outputPath = path.join(jobDir, `${stem}.${format}`)
|
||||
|
||||
if (source) args.push("--source", source)
|
||||
if (scannerName) args.push("--device-name", scannerName)
|
||||
try {
|
||||
// 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 })
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || `scanimage wurde mit Code ${result.code} beendet`)
|
||||
}
|
||||
|
||||
if (shouldPostprocess) {
|
||||
if (!await hasOpenCvPostprocessRuntime(config)) {
|
||||
const message = "OpenCV-Nachbearbeitung ist aktiviert, aber python3 mit cv2, Pillow und numpy ist nicht verfügbar"
|
||||
if (config.postprocessStrict) throw new Error(message)
|
||||
|
||||
log.warn(`${message}. Rohscan wird ohne Korrektur hochgeladen.`, {
|
||||
jobId: job.id,
|
||||
python: config.postprocessPython,
|
||||
})
|
||||
return fallbackRawResult(scanOutputPath, job.id)
|
||||
// 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 && !(batch && result.code === 7)) {
|
||||
throw new Error(result.stderr.trim() || `scanimage wurde mit Code ${result.code} beendet`)
|
||||
}
|
||||
const names = (await readdir(jobDir))
|
||||
.filter((name) => /^page-\d+\.png$/.test(name))
|
||||
.sort((a, b) => Number(a.match(/\d+/)![0]) - Number(b.match(/\d+/)![0]))
|
||||
const pages: string[] = []
|
||||
for (const [index, name] of names.entries()) {
|
||||
const page = path.join(jobDir, name)
|
||||
// Some scanimage versions leave a zero-byte file for the final NO_DOCS attempt.
|
||||
if ((await stat(page)).size === 0) {
|
||||
if (batch && result.code === 7 && index === names.length - 1) continue
|
||||
throw new Error(`Unvollständige Scan-Seite: ${name}`)
|
||||
}
|
||||
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 {
|
||||
return await postprocessScan(config, scanOutputPath, filename, format, postprocessProfile)
|
||||
} catch (error) {
|
||||
if (config.postprocessStrict) throw error
|
||||
|
||||
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",
|
||||
await reportProgress(`${pages.length} Seiten gescannt. Datei wird erstellt...`)
|
||||
return await createScanDocument(config, pages, outputPath, resolution, postprocess, profile as AgentConfig["postprocessProfile"])
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`${message} Rohscans bleiben zur Wiederherstellung in ${jobDir}.`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type AgentConfig = {
|
||||
scanResolution: number
|
||||
scanMode: string
|
||||
scanSource?: string
|
||||
scanTimeoutSeconds: number
|
||||
scanPostprocess: boolean
|
||||
postprocessProfile: "document" | "receipt" | "raw"
|
||||
postprocessPython: string
|
||||
@@ -45,4 +46,6 @@ export type ScanResult = {
|
||||
path: string
|
||||
filename: string
|
||||
mimeType: string
|
||||
pageCount: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
154
agents/fedeo-device-agent/tests/scan.test.ts
Normal file
154
agents/fedeo-device-agent/tests/scan.test.ts
Normal 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)
|
||||
})
|
||||
93
agents/fedeo-device-agent/tests/scan_document_test.py
Normal file
93
agents/fedeo-device-agent/tests/scan_document_test.py
Normal 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()
|
||||
@@ -5,7 +5,6 @@
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"],
|
||||
"typeRoots": ["../../backend/node_modules/@types"],
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { FastifyInstance, FastifyRequest } from "fastify"
|
||||
import multipart from "@fastify/multipart"
|
||||
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 { saveFile } from "../utils/files"
|
||||
|
||||
@@ -178,11 +179,12 @@ export default async function instanceAgentGatewayRoutes(server: FastifyInstance
|
||||
})
|
||||
.where(and(
|
||||
eq(instanceAgentScanJobs.id, req.params.id),
|
||||
eq(instanceAgentScanJobs.agentId, agent.id)
|
||||
eq(instanceAgentScanJobs.agentId, agent.id),
|
||||
inArray(instanceAgentScanJobs.status, ["pending", "running"])
|
||||
))
|
||||
.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 }
|
||||
})
|
||||
@@ -209,7 +211,8 @@ export default async function instanceAgentGatewayRoutes(server: FastifyInstance
|
||||
if (!data?.file) return reply.code(400).send({ error: "No file uploaded" })
|
||||
|
||||
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(
|
||||
server,
|
||||
|
||||
89
backend/tests/instanceAgentGateway.test.ts
Normal file
89
backend/tests/instanceAgentGateway.test.ts
Normal 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")
|
||||
})
|
||||
@@ -22,6 +22,8 @@ const agents = ref([])
|
||||
const selectedAgentId = ref(null)
|
||||
const currentJob = ref(null)
|
||||
const statusMessage = ref("")
|
||||
let disposed = false
|
||||
onBeforeUnmount(() => { disposed = true })
|
||||
|
||||
const scanForm = reactive({
|
||||
filename: `scan-${new Date().toISOString().slice(0, 10)}.pdf`,
|
||||
@@ -30,10 +32,27 @@ const scanForm = reactive({
|
||||
resolution: 300,
|
||||
mode: "Color",
|
||||
source: "ADF Duplex",
|
||||
postprocess: true,
|
||||
postprocessProfile: "receipt"
|
||||
paperSize: "A4",
|
||||
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(() =>
|
||||
agents.value.filter((agent) => agent.active && agent.capabilities?.scan)
|
||||
)
|
||||
@@ -62,8 +81,10 @@ const applyAgentDefaults = (agent) => {
|
||||
scanForm.resolution = Number(agent.scanDefaults?.resolution || 300)
|
||||
scanForm.mode = agent.scanDefaults?.mode || "Color"
|
||||
scanForm.source = agent.scanDefaults?.source || "ADF Duplex"
|
||||
scanForm.postprocess = agent.scanDefaults?.postprocess !== false
|
||||
scanForm.postprocessProfile = agent.scanDefaults?.postprocessProfile || "receipt"
|
||||
scanForm.paperSize = agent.scanDefaults?.paperSize || "A4"
|
||||
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-")) {
|
||||
scanForm.filename = `scan-${new Date().toISOString().slice(0, 10)}.${scanForm.format || "pdf"}`
|
||||
@@ -94,10 +115,19 @@ const loadAgents = async () => {
|
||||
}
|
||||
|
||||
const waitForJob = async (jobId) => {
|
||||
for (let i = 0; i < 90; i++) {
|
||||
while (!disposed) {
|
||||
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
|
||||
currentJob.value = job
|
||||
|
||||
@@ -106,16 +136,15 @@ const waitForJob = async (jobId) => {
|
||||
throw new Error(job.agentMessage || "Scan-Auftrag wurde abgebrochen.")
|
||||
}
|
||||
|
||||
statusMessage.value = job?.status === "running"
|
||||
statusMessage.value = job?.agentMessage || (job?.status === "running"
|
||||
? "Scanner arbeitet..."
|
||||
: "Warte auf den Agenten..."
|
||||
: "Warte auf den Agenten...")
|
||||
}
|
||||
|
||||
throw new Error("Der Scan-Auftrag läuft länger als erwartet.")
|
||||
return null
|
||||
}
|
||||
|
||||
const startScan = async () => {
|
||||
if (!selectedAgentId.value) return
|
||||
if (!selectedAgentId.value || scanInProgress.value) return
|
||||
|
||||
scanInProgress.value = true
|
||||
statusMessage.value = "Scan-Auftrag wird erstellt..."
|
||||
@@ -133,6 +162,7 @@ const startScan = async () => {
|
||||
resolution: Number(scanForm.resolution || 300),
|
||||
mode: scanForm.mode || "Color",
|
||||
source: scanForm.source || null,
|
||||
paperSize: scanForm.paperSize,
|
||||
postprocess: scanForm.postprocess,
|
||||
postprocessProfile: scanForm.postprocessProfile
|
||||
},
|
||||
@@ -150,17 +180,19 @@ const startScan = async () => {
|
||||
|
||||
statusMessage.value = "Warte auf den Agenten..."
|
||||
|
||||
await waitForJob(response.job.id)
|
||||
const finishedJob = await waitForJob(response.job.id)
|
||||
if (!finishedJob || disposed) return
|
||||
|
||||
toast.add({
|
||||
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"
|
||||
})
|
||||
|
||||
emit("scanFinished")
|
||||
modal.close()
|
||||
} catch (err) {
|
||||
if (disposed) return
|
||||
toast.add({
|
||||
title: "Scan fehlgeschlagen",
|
||||
description: err?.data?.error || err?.message || "Unbekannter Fehler",
|
||||
@@ -246,6 +278,10 @@ loadAgents()
|
||||
<UInput v-model="scanForm.filename" :disabled="scanInProgress" />
|
||||
</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">
|
||||
<UFormField label="Quelle">
|
||||
<UInput v-model="scanForm.source" placeholder="ADF Duplex" :disabled="scanInProgress" />
|
||||
@@ -255,6 +291,17 @@ loadAgents()
|
||||
</UFormField>
|
||||
</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">
|
||||
<UFormField label="Modus">
|
||||
<UInput v-model="scanForm.mode" placeholder="Color" :disabled="scanInProgress" />
|
||||
@@ -262,11 +309,7 @@ loadAgents()
|
||||
<UFormField label="Format">
|
||||
<USelectMenu
|
||||
v-model="scanForm.format"
|
||||
:items="[
|
||||
{ label: 'PDF', value: 'pdf' },
|
||||
{ label: 'PNG', value: 'png' },
|
||||
{ label: 'TIFF', value: 'tiff' }
|
||||
]"
|
||||
:items="formatOptions"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
:disabled="scanInProgress"
|
||||
@@ -307,7 +350,9 @@ loadAgents()
|
||||
|
||||
<template #footer>
|
||||
<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
|
||||
icon="i-heroicons-document-magnifying-glass"
|
||||
:loading="scanInProgress"
|
||||
|
||||
@@ -27,6 +27,7 @@ const editState = reactive({
|
||||
resolution: 300,
|
||||
mode: "Color",
|
||||
source: "",
|
||||
paperSize: "A4",
|
||||
postprocess: false,
|
||||
postprocessProfile: "document",
|
||||
})
|
||||
@@ -64,10 +65,18 @@ const applyAgentToForm = (agent: InstanceAgent | null) => {
|
||||
editState.resolution = Number(agent.scanDefaults?.resolution || 300)
|
||||
editState.mode = agent.scanDefaults?.mode || "Color"
|
||||
editState.source = agent.scanDefaults?.source || ""
|
||||
editState.paperSize = agent.scanDefaults?.paperSize || "A4"
|
||||
editState.postprocess = Boolean(agent.scanDefaults?.postprocess)
|
||||
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 })
|
||||
|
||||
const loadAgents = async () => {
|
||||
@@ -130,6 +139,10 @@ const createAgent = async () => {
|
||||
|
||||
const saveAgent = async () => {
|
||||
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
|
||||
|
||||
@@ -144,6 +157,7 @@ const saveAgent = async () => {
|
||||
resolution: Number(editState.resolution || 300),
|
||||
mode: editState.mode,
|
||||
source: editState.source || null,
|
||||
paperSize: editState.paperSize,
|
||||
postprocess: editState.postprocess,
|
||||
postprocessProfile: editState.postprocessProfile,
|
||||
},
|
||||
@@ -315,6 +329,10 @@ onMounted(async () => {
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UButton color="neutral" variant="soft" @click="applyIx1600Preset">
|
||||
ScanSnap iX1600: A4, Duplex, PDF
|
||||
</UButton>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<UFormField label="Format">
|
||||
<USelectMenu
|
||||
@@ -342,6 +360,13 @@ onMounted(async () => {
|
||||
</UFormField>
|
||||
</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)]">
|
||||
<UCheckbox v-model="editState.postprocess" label="OpenCV-Nachbearbeitung" />
|
||||
<UFormField label="Profil">
|
||||
|
||||
72
frontend/tests/file-scan.test.cjs
Normal file
72
frontend/tests/file-scan.test.cjs
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user