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

@@ -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)
}
}

View File

@@ -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,
})
})
})

View File

@@ -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(),

View File

@@ -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))

View File

@@ -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,
}
}

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 { 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}.`)
}
}

View File

@@ -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[]
}