import * as plugins from '../plugins.js'; import type { ISaneDevice, ISaneOption, ISaneParameters, IScanOptions, IScanResult, TColorMode, } from '../interfaces/index.js'; /** * SANE network protocol RPC codes */ const enum SaneRpc { INIT = 0, GET_DEVICES = 1, OPEN = 2, CLOSE = 3, GET_OPTION_DESCRIPTORS = 4, CONTROL_OPTION = 5, GET_PARAMETERS = 6, START = 7, CANCEL = 8, AUTHORIZE = 9, EXIT = 10, } /** * SANE status codes */ const enum SaneStatus { GOOD = 0, UNSUPPORTED = 1, CANCELLED = 2, DEVICE_BUSY = 3, INVAL = 4, EOF = 5, JAMMED = 6, NO_DOCS = 7, COVER_OPEN = 8, IO_ERROR = 9, NO_MEM = 10, ACCESS_DENIED = 11, } /** * SANE option types */ const enum SaneValueType { BOOL = 0, INT = 1, FIXED = 2, STRING = 3, BUTTON = 4, GROUP = 5, } /** * SANE option units */ const enum SaneUnit { NONE = 0, PIXEL = 1, BIT = 2, MM = 3, DPI = 4, PERCENT = 5, MICROSECOND = 6, } /** * SANE constraint types */ const enum SaneConstraintType { NONE = 0, RANGE = 1, WORD_LIST = 2, STRING_LIST = 3, } /** * SANE control option actions */ const enum SaneAction { GET_VALUE = 0, SET_VALUE = 1, SET_AUTO = 2, } const SANE_PORT = 6566; const SANE_NET_PROTOCOL_VERSION = 3; /** * Status code to error message mapping */ const STATUS_MESSAGES: Record = { [SaneStatus.GOOD]: 'Success', [SaneStatus.UNSUPPORTED]: 'Operation not supported', [SaneStatus.CANCELLED]: 'Operation cancelled', [SaneStatus.DEVICE_BUSY]: 'Device busy', [SaneStatus.INVAL]: 'Invalid argument', [SaneStatus.EOF]: 'End of file', [SaneStatus.JAMMED]: 'Document feeder jammed', [SaneStatus.NO_DOCS]: 'No documents in feeder', [SaneStatus.COVER_OPEN]: 'Scanner cover open', [SaneStatus.IO_ERROR]: 'I/O error', [SaneStatus.NO_MEM]: 'Out of memory', [SaneStatus.ACCESS_DENIED]: 'Access denied', }; /** * SANE network protocol client */ export class SaneProtocol { private socket: plugins.net.Socket | null = null; private address: string; private port: number; private handle: number = -1; private options: ISaneOption[] = []; private readBuffer: Buffer = Buffer.alloc(0); constructor(address: string, port: number = SANE_PORT) { this.address = address; this.port = port; } /** * Connect to SANE daemon */ public async connect(): Promise { return new Promise((resolve, reject) => { this.socket = plugins.net.createConnection( { host: this.address, port: this.port }, () => { this.init() .then(() => resolve()) .catch(reject); } ); this.socket.on('error', reject); this.socket.on('data', (data: Buffer) => { this.readBuffer = Buffer.concat([this.readBuffer, data]); }); }); } /** * Disconnect from SANE daemon */ public async disconnect(): Promise { if (this.handle >= 0) { await this.close(); } await this.exit(); if (this.socket) { this.socket.destroy(); this.socket = null; } } /** * Initialize connection (SANE_NET_INIT) */ private async init(): Promise { const request = this.buildRequest(SaneRpc.INIT); this.writeWord(request, SANE_NET_PROTOCOL_VERSION); this.writeString(request, ''); // Username (empty for now) await this.sendRequest(request); const response = await this.readResponse(); const status = this.readWord(response); const version = this.readWord(response); if (status !== SaneStatus.GOOD) { throw new Error(`SANE init failed: ${STATUS_MESSAGES[status] || 'Unknown error'}`); } } /** * Get available devices (SANE_NET_GET_DEVICES) */ public async getDevices(): Promise { const request = this.buildRequest(SaneRpc.GET_DEVICES); await this.sendRequest(request); const response = await this.readResponse(); const status = this.readWord(response); if (status !== SaneStatus.GOOD) { throw new Error(`Failed to get devices: ${STATUS_MESSAGES[status]}`); } const devices: ISaneDevice[] = []; const count = this.readWord(response); for (let i = 0; i < count; i++) { const hasDevice = this.readWord(response); if (hasDevice) { devices.push({ name: this.readString(response), vendor: this.readString(response), model: this.readString(response), type: this.readString(response), }); } } return devices; } /** * Open a device (SANE_NET_OPEN) */ public async open(deviceName: string): Promise { const request = this.buildRequest(SaneRpc.OPEN); this.writeString(request, deviceName); await this.sendRequest(request); const response = await this.readResponse(); const status = this.readWord(response); if (status !== SaneStatus.GOOD) { // Check for authorization required const resource = this.readString(response); if (resource) { throw new Error(`Authorization required for: ${resource}`); } throw new Error(`Failed to open device: ${STATUS_MESSAGES[status]}`); } this.handle = this.readWord(response); this.readString(response); // resource (should be empty on success) // Get option descriptors await this.getOptionDescriptors(); } /** * Close the current device (SANE_NET_CLOSE) */ public async close(): Promise { if (this.handle < 0) { return; } const request = this.buildRequest(SaneRpc.CLOSE); this.writeWord(request, this.handle); await this.sendRequest(request); await this.readResponse(); // Just read to clear buffer this.handle = -1; this.options = []; } /** * Exit connection (SANE_NET_EXIT) */ private async exit(): Promise { const request = this.buildRequest(SaneRpc.EXIT); await this.sendRequest(request); } /** * Get option descriptors (SANE_NET_GET_OPTION_DESCRIPTORS) */ private async getOptionDescriptors(): Promise { const request = this.buildRequest(SaneRpc.GET_OPTION_DESCRIPTORS); this.writeWord(request, this.handle); await this.sendRequest(request); const response = await this.readResponse(); const count = this.readWord(response); this.options = []; for (let i = 0; i < count; i++) { const hasOption = this.readWord(response); if (!hasOption) { continue; } const option: ISaneOption = { name: this.readString(response), title: this.readString(response), description: this.readString(response), type: this.mapValueType(this.readWord(response)), unit: this.mapUnit(this.readWord(response)), size: this.readWord(response), capabilities: this.readWord(response), constraintType: this.mapConstraintType(this.readWord(response)), }; // Read constraint based on type if (option.constraintType === 'range') { option.constraint = { range: { min: this.readWord(response), max: this.readWord(response), quant: this.readWord(response), }, }; } else if (option.constraintType === 'word_list') { const wordCount = this.readWord(response); const words: number[] = []; for (let j = 0; j < wordCount; j++) { words.push(this.readWord(response)); } option.constraint = { wordList: words }; } else if (option.constraintType === 'string_list') { const strings: string[] = []; let str: string; while ((str = this.readString(response)) !== '') { strings.push(str); } option.constraint = { stringList: strings }; } this.options.push(option); } } /** * Set an option value */ public async setOption(name: string, value: unknown): Promise { const optionIndex = this.options.findIndex((o) => o.name === name); if (optionIndex < 0) { throw new Error(`Unknown option: ${name}`); } const option = this.options[optionIndex]; const request = this.buildRequest(SaneRpc.CONTROL_OPTION); this.writeWord(request, this.handle); this.writeWord(request, optionIndex); this.writeWord(request, SaneAction.SET_VALUE); this.writeWord(request, option.type === 'string' ? (value as string).length + 1 : option.size); // Write value based on type if (option.type === 'bool' || option.type === 'int') { this.writeWord(request, value as number); } else if (option.type === 'fixed') { this.writeWord(request, Math.round((value as number) * 65536)); } else if (option.type === 'string') { this.writeString(request, value as string); } await this.sendRequest(request); const response = await this.readResponse(); const status = this.readWord(response); if (status !== SaneStatus.GOOD) { throw new Error(`Failed to set option ${name}: ${STATUS_MESSAGES[status]}`); } } /** * Get scan parameters (SANE_NET_GET_PARAMETERS) */ public async getParameters(): Promise { const request = this.buildRequest(SaneRpc.GET_PARAMETERS); this.writeWord(request, this.handle); await this.sendRequest(request); const response = await this.readResponse(); const status = this.readWord(response); if (status !== SaneStatus.GOOD) { throw new Error(`Failed to get parameters: ${STATUS_MESSAGES[status]}`); } const formatCode = this.readWord(response); const format = this.mapFormat(formatCode); return { format, lastFrame: this.readWord(response) === 1, bytesPerLine: this.readWord(response), pixelsPerLine: this.readWord(response), lines: this.readWord(response), depth: this.readWord(response), }; } /** * Start scanning (SANE_NET_START) */ public async start(): Promise<{ port: number; byteOrder: 'little' | 'big' }> { const request = this.buildRequest(SaneRpc.START); this.writeWord(request, this.handle); await this.sendRequest(request); const response = await this.readResponse(); const status = this.readWord(response); if (status !== SaneStatus.GOOD) { throw new Error(`Failed to start scan: ${STATUS_MESSAGES[status]}`); } const port = this.readWord(response); const byteOrder = this.readWord(response) === 0x1234 ? 'little' : 'big'; this.readString(response); // resource return { port, byteOrder }; } /** * Cancel scanning (SANE_NET_CANCEL) */ public async cancel(): Promise { const request = this.buildRequest(SaneRpc.CANCEL); this.writeWord(request, this.handle); await this.sendRequest(request); await this.readResponse(); } /** * Perform a complete scan */ public async scan(options: IScanOptions): Promise { // Configure scan options await this.configureOptions(options); // Get parameters const params = await this.getParameters(); // Start scan and get data port const { port, byteOrder } = await this.start(); // Connect to data port and read image data const imageData = await this.readImageData(port, params, byteOrder); return { data: imageData, format: options.format ?? 'png', width: params.pixelsPerLine, height: params.lines, resolution: options.resolution ?? 300, colorMode: options.colorMode ?? 'color', mimeType: `image/${options.format ?? 'png'}`, }; } /** * Configure scan options based on IScanOptions */ private async configureOptions(options: IScanOptions): Promise { // Set resolution if (options.resolution) { const resOption = this.options.find((o) => o.name === 'resolution'); if (resOption) { await this.setOption('resolution', options.resolution); } } // Set color mode if (options.colorMode) { const modeOption = this.options.find((o) => o.name === 'mode'); if (modeOption) { const modeValue = this.mapColorMode(options.colorMode); await this.setOption('mode', modeValue); } } // Set scan area if (options.area) { const tlxOption = this.options.find((o) => o.name === 'tl-x'); const tlyOption = this.options.find((o) => o.name === 'tl-y'); const brxOption = this.options.find((o) => o.name === 'br-x'); const bryOption = this.options.find((o) => o.name === 'br-y'); if (tlxOption) await this.setOption('tl-x', options.area.x); if (tlyOption) await this.setOption('tl-y', options.area.y); if (brxOption) await this.setOption('br-x', options.area.x + options.area.width); if (bryOption) await this.setOption('br-y', options.area.y + options.area.height); } // Set source if (options.source) { const sourceOption = this.options.find((o) => o.name === 'source'); if (sourceOption) { await this.setOption('source', options.source); } } } /** * Read image data from data port */ private async readImageData( port: number, params: ISaneParameters, _byteOrder: 'little' | 'big' ): Promise { return new Promise((resolve, reject) => { const dataSocket = plugins.net.createConnection( { host: this.address, port }, () => { const chunks: Buffer[] = []; dataSocket.on('data', (data: Buffer) => { // Data format: 4 bytes length + data // Read records until length is 0xFFFFFFFF (end marker) let offset = 0; while (offset < data.length) { if (offset + 4 > data.length) { break; } const length = data.readUInt32BE(offset); offset += 4; if (length === 0xffffffff) { // End of data dataSocket.destroy(); resolve(Buffer.concat(chunks)); return; } if (offset + length > data.length) { // Incomplete record, wait for more data break; } chunks.push(data.subarray(offset, offset + length)); offset += length; } }); dataSocket.on('end', () => { resolve(Buffer.concat(chunks)); }); dataSocket.on('error', reject); } ); dataSocket.on('error', reject); }); } /** * Map color mode to SANE mode string */ private mapColorMode(mode: TColorMode): string { switch (mode) { case 'color': return 'Color'; case 'grayscale': return 'Gray'; case 'blackwhite': return 'Lineart'; default: return 'Color'; } } /** * Map SANE format code to string */ private mapFormat(code: number): ISaneParameters['format'] { const formats: Record = { 0: 'gray', 1: 'rgb', 2: 'red', 3: 'green', 4: 'blue', }; return formats[code] ?? 'gray'; } /** * Map value type code to string */ private mapValueType(code: number): ISaneOption['type'] { const types: Record = { [SaneValueType.BOOL]: 'bool', [SaneValueType.INT]: 'int', [SaneValueType.FIXED]: 'fixed', [SaneValueType.STRING]: 'string', [SaneValueType.BUTTON]: 'button', [SaneValueType.GROUP]: 'group', }; return types[code] ?? 'int'; } /** * Map unit code to string */ private mapUnit(code: number): ISaneOption['unit'] { const units: Record = { [SaneUnit.NONE]: 'none', [SaneUnit.PIXEL]: 'pixel', [SaneUnit.BIT]: 'bit', [SaneUnit.MM]: 'mm', [SaneUnit.DPI]: 'dpi', [SaneUnit.PERCENT]: 'percent', [SaneUnit.MICROSECOND]: 'microsecond', }; return units[code] ?? 'none'; } /** * Map constraint type code to string */ private mapConstraintType(code: number): ISaneOption['constraintType'] { const types: Record = { [SaneConstraintType.NONE]: 'none', [SaneConstraintType.RANGE]: 'range', [SaneConstraintType.WORD_LIST]: 'word_list', [SaneConstraintType.STRING_LIST]: 'string_list', }; return types[code] ?? 'none'; } // ========================================================================= // Low-level protocol helpers // ========================================================================= private buildRequest(rpc: SaneRpc): Buffer[] { const chunks: Buffer[] = []; const header = Buffer.alloc(4); header.writeUInt32BE(rpc, 0); chunks.push(header); return chunks; } private writeWord(chunks: Buffer[], value: number): void { const buf = Buffer.alloc(4); buf.writeUInt32BE(value >>> 0, 0); chunks.push(buf); } private writeString(chunks: Buffer[], str: string): void { const strBuf = Buffer.from(str + '\0', 'utf-8'); this.writeWord(chunks, strBuf.length); chunks.push(strBuf); } private async sendRequest(chunks: Buffer[]): Promise { if (!this.socket) { throw new Error('Not connected'); } const data = Buffer.concat(chunks); return new Promise((resolve, reject) => { this.socket!.write(data, (err) => { if (err) reject(err); else resolve(); }); }); } private async readResponse(): Promise<{ buffer: Buffer; offset: number }> { // Wait for data await this.waitForData(4); return { buffer: this.readBuffer, offset: 0 }; } private async waitForData(minBytes: number): Promise { const startTime = Date.now(); const timeout = 30000; while (this.readBuffer.length < minBytes) { if (Date.now() - startTime > timeout) { throw new Error('Timeout waiting for SANE response'); } await plugins.smartdelay.delayFor(10); } } private readWord(response: { buffer: Buffer; offset: number }): number { const value = response.buffer.readUInt32BE(response.offset); response.offset += 4; return value; } private readString(response: { buffer: Buffer; offset: number }): string { const length = this.readWord(response); if (length === 0) { return ''; } const str = response.buffer.toString('utf-8', response.offset, response.offset + length - 1); response.offset += length; return str; } }