Geräte-Agent für lokale Scan-Aufträge anlegen
This commit is contained in:
10
agents/fedeo-device-agent/.env.example
Normal file
10
agents/fedeo-device-agent/.env.example
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FEDEO_URL=https://fedeo.example.com
|
||||||
|
FEDEO_AGENT_TOKEN=fedeo_agent_REPLACE_ME
|
||||||
|
FEDEO_POLL_SECONDS=5
|
||||||
|
FEDEO_WORK_DIR=/tmp/fedeo-device-agent
|
||||||
|
FEDEO_SCANNER_NAME=
|
||||||
|
FEDEO_PRINTER_NAME=
|
||||||
|
FEDEO_SCAN_FORMAT=pdf
|
||||||
|
FEDEO_SCAN_RESOLUTION=300
|
||||||
|
FEDEO_SCAN_MODE=Color
|
||||||
|
FEDEO_SCAN_SOURCE=
|
||||||
5
agents/fedeo-device-agent/.gitignore
vendored
Normal file
5
agents/fedeo-device-agent/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
84
agents/fedeo-device-agent/README.md
Normal file
84
agents/fedeo-device-agent/README.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# FEDEO Geräte-Agent
|
||||||
|
|
||||||
|
Der FEDEO Geräte-Agent läuft lokal auf macOS, Linux oder Raspberry Pi OS. Er holt instanzweite Scan-Aufträge von FEDEO ab, führt sie auf einem lokal angeschlossenen Scanner aus und lädt das Ergebnis wieder in FEDEO hoch.
|
||||||
|
|
||||||
|
Der Agent ist nicht an einen Mandanten gebunden. Jeder Auftrag enthält seinen Tenant selbst.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install node sane-backends
|
||||||
|
scanimage -L
|
||||||
|
```
|
||||||
|
|
||||||
|
Drucken nutzt später das macOS-Drucksystem/CUPS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lpstat -p
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux und Raspberry Pi OS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y nodejs npm sane-utils cups
|
||||||
|
scanimage -L
|
||||||
|
lpstat -p
|
||||||
|
```
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Wichtige Werte:
|
||||||
|
|
||||||
|
```env
|
||||||
|
FEDEO_URL=https://deine-fedeo-instanz
|
||||||
|
FEDEO_AGENT_TOKEN=fedeo_agent_...
|
||||||
|
FEDEO_SCANNER_NAME=
|
||||||
|
FEDEO_POLL_SECONDS=5
|
||||||
|
```
|
||||||
|
|
||||||
|
Wenn `FEDEO_SCANNER_NAME` leer bleibt, verwendet `scanimage` den Standard-Scanner.
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## FEDEO-Endpunkte
|
||||||
|
|
||||||
|
Der Agent nutzt:
|
||||||
|
|
||||||
|
- `POST /instance-agent/heartbeat`
|
||||||
|
- `GET /instance-agent/scan-jobs/next`
|
||||||
|
- `POST /instance-agent/scan-jobs/:id/status`
|
||||||
|
- `POST /instance-agent/scan-jobs/:id/upload`
|
||||||
|
|
||||||
|
## macOS Autostart
|
||||||
|
|
||||||
|
Die Vorlage liegt unter `system/macos/com.fedeo.device-agent.plist`. Nach Anpassung der Pfade kann sie als LaunchAgent installiert werden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/Library/LaunchAgents
|
||||||
|
cp system/macos/com.fedeo.device-agent.plist ~/Library/LaunchAgents/
|
||||||
|
launchctl load ~/Library/LaunchAgents/com.fedeo.device-agent.plist
|
||||||
|
```
|
||||||
|
|
||||||
|
## Linux Autostart
|
||||||
|
|
||||||
|
Die Vorlage liegt unter `system/linux/fedeo-device-agent.service`.
|
||||||
25
agents/fedeo-device-agent/package.json
Normal file
25
agents/fedeo-device-agent/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@fedeo/device-agent",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Lokaler FEDEO Druck- und Scan-Agent für macOS, Linux und Raspberry Pi OS.",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/main.js",
|
||||||
|
"bin": {
|
||||||
|
"fedeo-device-agent": "dist/main.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"dev": "tsx src/main.ts",
|
||||||
|
"start": "node dist/main.js"
|
||||||
|
},
|
||||||
|
"dependencies": {},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.3.0",
|
||||||
|
"tsx": "^4.20.5",
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
67
agents/fedeo-device-agent/src/api.ts
Normal file
67
agents/fedeo-device-agent/src/api.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { readFile } from "node:fs/promises"
|
||||||
|
import { basename } from "node:path"
|
||||||
|
import { AgentConfig, AgentHeartbeat, NextScanJobResponse, ScanResult } from "./types.js"
|
||||||
|
|
||||||
|
export class FedeoApi {
|
||||||
|
constructor(private readonly config: AgentConfig) {}
|
||||||
|
|
||||||
|
private url(path: string) {
|
||||||
|
return `${this.config.fedeoUrl}${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private headers(extra?: HeadersInit): HeadersInit {
|
||||||
|
return {
|
||||||
|
"X-Agent-Token": this.config.agentToken,
|
||||||
|
...extra,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const response = await fetch(this.url(path), {
|
||||||
|
...init,
|
||||||
|
headers: this.headers(init.headers),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text().catch(() => "")
|
||||||
|
throw new Error(`${init.method || "GET"} ${path} fehlgeschlagen: ${response.status} ${body}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json() as T
|
||||||
|
}
|
||||||
|
|
||||||
|
heartbeat(payload: AgentHeartbeat) {
|
||||||
|
return this.request<{ status: string; pendingScanJobs: number }>("/instance-agent/heartbeat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
nextScanJob() {
|
||||||
|
return this.request<NextScanJobResponse>("/instance-agent/scan-jobs/next")
|
||||||
|
}
|
||||||
|
|
||||||
|
updateScanJobStatus(jobId: string, status: "running" | "failed" | "canceled", message?: string) {
|
||||||
|
return this.request(`/instance-agent/scan-jobs/${jobId}/status`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status, message }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadScan(jobId: string, result: ScanResult) {
|
||||||
|
const form = new FormData()
|
||||||
|
const fileBuffer = await readFile(result.path)
|
||||||
|
const file = new File([fileBuffer], result.filename || basename(result.path), {
|
||||||
|
type: result.mimeType,
|
||||||
|
})
|
||||||
|
|
||||||
|
form.append("file", file)
|
||||||
|
|
||||||
|
return this.request(`/instance-agent/scan-jobs/${jobId}/upload`, {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
48
agents/fedeo-device-agent/src/commands.ts
Normal file
48
agents/fedeo-device-agent/src/commands.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { spawn } from "node:child_process"
|
||||||
|
|
||||||
|
export type CommandResult = {
|
||||||
|
stdout: string
|
||||||
|
stderr: string
|
||||||
|
code: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const commandExists = (command: string) =>
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
const child = spawn("sh", ["-lc", `command -v ${command}`])
|
||||||
|
child.on("error", () => resolve(false))
|
||||||
|
child.on("close", (code) => resolve(code === 0))
|
||||||
|
})
|
||||||
|
|
||||||
|
export const runCommand = (
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options: { timeoutMs?: number } = {}
|
||||||
|
) =>
|
||||||
|
new Promise<CommandResult>((resolve, reject) => {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
})
|
||||||
|
|
||||||
|
const stdout: Buffer[] = []
|
||||||
|
const stderr: Buffer[] = []
|
||||||
|
|
||||||
|
const timeout = options.timeoutMs
|
||||||
|
? setTimeout(() => {
|
||||||
|
child.kill("SIGTERM")
|
||||||
|
reject(new Error(`${command} wurde nach ${options.timeoutMs} ms beendet`))
|
||||||
|
}, 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) => {
|
||||||
|
if (timeout) clearTimeout(timeout)
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
stdout: Buffer.concat(stdout).toString("utf8"),
|
||||||
|
stderr: Buffer.concat(stderr).toString("utf8"),
|
||||||
|
code: code ?? 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
44
agents/fedeo-device-agent/src/config.ts
Normal file
44
agents/fedeo-device-agent/src/config.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import path from "node:path"
|
||||||
|
import os from "node:os"
|
||||||
|
import { AgentConfig } from "./types.js"
|
||||||
|
import { loadDotEnv } from "./env.js"
|
||||||
|
|
||||||
|
const optional = (value: string | undefined) => {
|
||||||
|
const trimmed = value?.trim()
|
||||||
|
return trimmed ? trimmed : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberFromEnv = (value: string | undefined, fallback: number) => {
|
||||||
|
if (!value) return fallback
|
||||||
|
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const scanFormatFromEnv = (value: string | undefined): AgentConfig["scanFormat"] => {
|
||||||
|
if (value === "png" || value === "tiff" || value === "pdf") return value
|
||||||
|
return "pdf"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadConfig = (): AgentConfig => {
|
||||||
|
loadDotEnv(process.env.FEDEO_AGENT_ENV || ".env")
|
||||||
|
|
||||||
|
const fedeoUrl = optional(process.env.FEDEO_URL)
|
||||||
|
const agentToken = optional(process.env.FEDEO_AGENT_TOKEN)
|
||||||
|
|
||||||
|
if (!fedeoUrl) throw new Error("FEDEO_URL fehlt")
|
||||||
|
if (!agentToken) throw new Error("FEDEO_AGENT_TOKEN fehlt")
|
||||||
|
|
||||||
|
return {
|
||||||
|
fedeoUrl: fedeoUrl.replace(/\/+$/, ""),
|
||||||
|
agentToken,
|
||||||
|
pollSeconds: numberFromEnv(process.env.FEDEO_POLL_SECONDS, 5),
|
||||||
|
workDir: optional(process.env.FEDEO_WORK_DIR) || path.join(os.tmpdir(), "fedeo-device-agent"),
|
||||||
|
scannerName: optional(process.env.FEDEO_SCANNER_NAME),
|
||||||
|
printerName: optional(process.env.FEDEO_PRINTER_NAME),
|
||||||
|
scanFormat: scanFormatFromEnv(process.env.FEDEO_SCAN_FORMAT),
|
||||||
|
scanResolution: numberFromEnv(process.env.FEDEO_SCAN_RESOLUTION, 300),
|
||||||
|
scanMode: optional(process.env.FEDEO_SCAN_MODE) || "Color",
|
||||||
|
scanSource: optional(process.env.FEDEO_SCAN_SOURCE),
|
||||||
|
}
|
||||||
|
}
|
||||||
32
agents/fedeo-device-agent/src/env.ts
Normal file
32
agents/fedeo-device-agent/src/env.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { readFileSync, existsSync } from "node:fs"
|
||||||
|
|
||||||
|
const parseEnvLine = (line: string) => {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) return null
|
||||||
|
|
||||||
|
const separator = trimmed.indexOf("=")
|
||||||
|
if (separator === -1) return null
|
||||||
|
|
||||||
|
const key = trimmed.slice(0, separator).trim()
|
||||||
|
let value = trimmed.slice(separator + 1).trim()
|
||||||
|
|
||||||
|
if (
|
||||||
|
(value.startsWith("\"") && value.endsWith("\"")) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { key, value }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadDotEnv = (path = ".env") => {
|
||||||
|
if (!existsSync(path)) return
|
||||||
|
|
||||||
|
const content = readFileSync(path, "utf8")
|
||||||
|
for (const line of content.split(/\r?\n/)) {
|
||||||
|
const parsed = parseEnvLine(line)
|
||||||
|
if (!parsed) continue
|
||||||
|
if (process.env[parsed.key] === undefined) process.env[parsed.key] = parsed.value
|
||||||
|
}
|
||||||
|
}
|
||||||
30
agents/fedeo-device-agent/src/logger.ts
Normal file
30
agents/fedeo-device-agent/src/logger.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const timestamp = () => new Date().toISOString()
|
||||||
|
|
||||||
|
export const log = {
|
||||||
|
info(message: string, meta?: unknown) {
|
||||||
|
if (meta === undefined) {
|
||||||
|
console.log(`[${timestamp()}] INFO ${message}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[${timestamp()}] INFO ${message}`, meta)
|
||||||
|
},
|
||||||
|
|
||||||
|
warn(message: string, meta?: unknown) {
|
||||||
|
if (meta === undefined) {
|
||||||
|
console.warn(`[${timestamp()}] WARN ${message}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`[${timestamp()}] WARN ${message}`, meta)
|
||||||
|
},
|
||||||
|
|
||||||
|
error(message: string, meta?: unknown) {
|
||||||
|
if (meta === undefined) {
|
||||||
|
console.error(`[${timestamp()}] ERROR ${message}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`[${timestamp()}] ERROR ${message}`, meta)
|
||||||
|
},
|
||||||
|
}
|
||||||
93
agents/fedeo-device-agent/src/main.ts
Normal file
93
agents/fedeo-device-agent/src/main.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import os from "node:os"
|
||||||
|
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"
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
const stringifyError = (error: unknown) => {
|
||||||
|
if (error instanceof Error) return error.message
|
||||||
|
return String(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const config = loadConfig()
|
||||||
|
const api = new FedeoApi(config)
|
||||||
|
|
||||||
|
log.info("FEDEO Geräte-Agent startet", {
|
||||||
|
platform: process.platform,
|
||||||
|
workDir: config.workDir,
|
||||||
|
pollSeconds: config.pollSeconds,
|
||||||
|
})
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const scannerNames = await listScanners()
|
||||||
|
const printerNames = await listPrinters()
|
||||||
|
const scanAvailable = await hasSane()
|
||||||
|
|
||||||
|
const heartbeat = await api.heartbeat({
|
||||||
|
capabilities: {
|
||||||
|
scan: scanAvailable,
|
||||||
|
print: printerNames.length > 0,
|
||||||
|
platform: process.platform,
|
||||||
|
},
|
||||||
|
scannerNames,
|
||||||
|
printerNames,
|
||||||
|
debugInfo: {
|
||||||
|
hostname: os.hostname(),
|
||||||
|
release: os.release(),
|
||||||
|
arch: os.arch(),
|
||||||
|
node: process.version,
|
||||||
|
uptimeSeconds: Math.round(os.uptime()),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (heartbeat.pendingScanJobs > 0) {
|
||||||
|
log.info(`${heartbeat.pendingScanJobs} Scan-Auftrag/Aufträge warten`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = await api.nextScanJob()
|
||||||
|
if (!next.job) {
|
||||||
|
await sleep(config.pollSeconds * 1000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Scan-Auftrag wird ausgeführt", {
|
||||||
|
jobId: next.job.id,
|
||||||
|
tenantId: next.job.tenantId,
|
||||||
|
scannerName: next.job.scannerName || config.scannerName || "default",
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.updateScanJobStatus(next.job.id, "running")
|
||||||
|
const scanResult = await runScan(config, next.job)
|
||||||
|
await api.uploadScan(next.job.id, scanResult)
|
||||||
|
|
||||||
|
log.info("Scan-Auftrag abgeschlossen", {
|
||||||
|
jobId: next.job.id,
|
||||||
|
file: scanResult.filename,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const message = stringifyError(error)
|
||||||
|
log.error("Scan-Auftrag fehlgeschlagen", {
|
||||||
|
jobId: next.job.id,
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
|
||||||
|
await api.updateScanJobStatus(next.job.id, "failed", message)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Agent-Schleife fehlgeschlagen", stringifyError(error))
|
||||||
|
await sleep(config.pollSeconds * 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
log.error("Agent konnte nicht gestartet werden", stringifyError(error))
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
15
agents/fedeo-device-agent/src/print/cups.ts
Normal file
15
agents/fedeo-device-agent/src/print/cups.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { commandExists, runCommand } from "../commands.js"
|
||||||
|
|
||||||
|
export const hasCups = () => commandExists("lpstat")
|
||||||
|
|
||||||
|
export const listPrinters = async () => {
|
||||||
|
if (!await hasCups()) return []
|
||||||
|
|
||||||
|
const result = await runCommand("lpstat", ["-p"], { timeoutMs: 10_000 })
|
||||||
|
if (result.code !== 0) return []
|
||||||
|
|
||||||
|
return result.stdout
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.match(/^printer\s+(\S+)/)?.[1])
|
||||||
|
.filter((printer): printer is string => Boolean(printer))
|
||||||
|
}
|
||||||
85
agents/fedeo-device-agent/src/scan/sane.ts
Normal file
85
agents/fedeo-device-agent/src/scan/sane.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { mkdirSync } from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
import { AgentConfig, ScanJob, ScanResult } from "../types.js"
|
||||||
|
import { commandExists, runCommand } from "../commands.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]
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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])
|
||||||
|
.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 })
|
||||||
|
|
||||||
|
const settings = job.settings || {}
|
||||||
|
const format = stringSetting(settings, "format") as AgentConfig["scanFormat"] | undefined || config.scanFormat
|
||||||
|
const resolution = numberSetting(settings, "resolution") || config.scanResolution
|
||||||
|
const mode = stringSetting(settings, "mode") || config.scanMode
|
||||||
|
const source = stringSetting(settings, "source") || config.scanSource
|
||||||
|
const scannerName = job.scannerName || config.scannerName
|
||||||
|
const filename = job.requestedFilename || `${job.id}.${format}`
|
||||||
|
const outputPath = path.join(config.workDir, filename)
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
"--format",
|
||||||
|
format,
|
||||||
|
"--resolution",
|
||||||
|
String(resolution),
|
||||||
|
"--mode",
|
||||||
|
mode,
|
||||||
|
"--output-file",
|
||||||
|
outputPath,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (source) args.push("--source", source)
|
||||||
|
if (scannerName) args.push("--device-name", scannerName)
|
||||||
|
|
||||||
|
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`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: outputPath,
|
||||||
|
filename,
|
||||||
|
mimeType: mimeTypes[format] || "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
44
agents/fedeo-device-agent/src/types.ts
Normal file
44
agents/fedeo-device-agent/src/types.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
export type AgentConfig = {
|
||||||
|
fedeoUrl: string
|
||||||
|
agentToken: string
|
||||||
|
pollSeconds: number
|
||||||
|
workDir: string
|
||||||
|
scannerName?: string
|
||||||
|
printerName?: string
|
||||||
|
scanFormat: "pdf" | "png" | "tiff"
|
||||||
|
scanResolution: number
|
||||||
|
scanMode: string
|
||||||
|
scanSource?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AgentHeartbeat = {
|
||||||
|
capabilities: {
|
||||||
|
scan: boolean
|
||||||
|
print: boolean
|
||||||
|
platform: NodeJS.Platform
|
||||||
|
}
|
||||||
|
scannerNames: string[]
|
||||||
|
printerNames: string[]
|
||||||
|
debugInfo: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScanJob = {
|
||||||
|
id: string
|
||||||
|
tenantId: number
|
||||||
|
agentId: string
|
||||||
|
status: string
|
||||||
|
scannerName?: string | null
|
||||||
|
requestedFilename?: string | null
|
||||||
|
settings?: Record<string, unknown>
|
||||||
|
target?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NextScanJobResponse = {
|
||||||
|
job: ScanJob | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScanResult = {
|
||||||
|
path: string
|
||||||
|
filename: string
|
||||||
|
mimeType: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=FEDEO Geräte-Agent
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
EnvironmentFile=/etc/fedeo-device-agent/config.env
|
||||||
|
WorkingDirectory=/opt/fedeo-device-agent
|
||||||
|
ExecStart=/usr/bin/node /opt/fedeo-device-agent/dist/main.js
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
User=fedeo-agent
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||||
|
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.fedeo.device-agent</string>
|
||||||
|
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/usr/local/bin/node</string>
|
||||||
|
<string>/opt/fedeo-device-agent/dist/main.js</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>FEDEO_AGENT_ENV</key>
|
||||||
|
<string>/opt/fedeo-device-agent/.env</string>
|
||||||
|
</dict>
|
||||||
|
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/tmp/fedeo-device-agent.log</string>
|
||||||
|
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/tmp/fedeo-device-agent.err.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
17
agents/fedeo-device-agent/tsconfig.json
Normal file
17
agents/fedeo-device-agent/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"types": ["node"],
|
||||||
|
"typeRoots": ["../../backend/node_modules/@types"],
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user