695 lines
18 KiB
TypeScript
695 lines
18 KiB
TypeScript
|
|
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<number, string> = {
|
||
|
|
[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<void> {
|
||
|
|
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<void> {
|
||
|
|
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<void> {
|
||
|
|
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<ISaneDevice[]> {
|
||
|
|
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<void> {
|
||
|
|
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<void> {
|
||
|
|
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<void> {
|
||
|
|
const request = this.buildRequest(SaneRpc.EXIT);
|
||
|
|
await this.sendRequest(request);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get option descriptors (SANE_NET_GET_OPTION_DESCRIPTORS)
|
||
|
|
*/
|
||
|
|
private async getOptionDescriptors(): Promise<void> {
|
||
|
|
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<void> {
|
||
|
|
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<ISaneParameters> {
|
||
|
|
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<void> {
|
||
|
|
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<IScanResult> {
|
||
|
|
// 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<void> {
|
||
|
|
// 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<Buffer> {
|
||
|
|
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<number, ISaneParameters['format']> = {
|
||
|
|
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<number, ISaneOption['type']> = {
|
||
|
|
[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<number, ISaneOption['unit']> = {
|
||
|
|
[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<number, ISaneOption['constraintType']> = {
|
||
|
|
[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<void> {
|
||
|
|
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<void> {
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
}
|