feat(devicemanager): Introduce a UniversalDevice architecture with composable Feature system; add extensive new device/protocol support and discovery/refactors
This commit is contained in:
424
ts/protocols/protocol.escl.ts
Normal file
424
ts/protocols/protocol.escl.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import * as plugins from '../plugins.js'; // Used for smartdelay
|
||||
import type {
|
||||
IEsclCapabilities,
|
||||
IEsclScanStatus,
|
||||
IEsclJobInfo,
|
||||
IScanOptions,
|
||||
IScanResult,
|
||||
TScanFormat,
|
||||
TColorMode,
|
||||
} from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* eSCL XML namespaces
|
||||
*/
|
||||
const NAMESPACES = {
|
||||
scan: 'http://schemas.hp.com/imaging/escl/2011/05/03',
|
||||
pwg: 'http://www.pwg.org/schemas/2010/12/sm',
|
||||
};
|
||||
|
||||
/**
|
||||
* Color mode mappings
|
||||
*/
|
||||
const COLOR_MODE_MAP: Record<TColorMode, string> = {
|
||||
color: 'RGB24',
|
||||
grayscale: 'Grayscale8',
|
||||
blackwhite: 'BlackAndWhite1',
|
||||
};
|
||||
|
||||
/**
|
||||
* Format MIME type mappings
|
||||
*/
|
||||
const FORMAT_MIME_MAP: Record<TScanFormat, string> = {
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
pdf: 'application/pdf',
|
||||
tiff: 'image/tiff',
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to make HTTP requests using native fetch (available in Node.js 18+)
|
||||
*/
|
||||
async function httpRequest(
|
||||
url: string,
|
||||
method: 'GET' | 'POST' | 'DELETE',
|
||||
body?: string
|
||||
): Promise<{ status: number; headers: Record<string, string>; body: string }> {
|
||||
const options: RequestInit = { method };
|
||||
|
||||
if (body) {
|
||||
options.headers = { 'Content-Type': 'text/xml; charset=utf-8' };
|
||||
options.body = body;
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
const responseBody = await response.text();
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
response.headers.forEach((value, key) => {
|
||||
headers[key.toLowerCase()] = value;
|
||||
});
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
headers,
|
||||
body: responseBody,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* eSCL/AirScan protocol client for network scanners
|
||||
*/
|
||||
export class EsclProtocol {
|
||||
private baseUrl: string;
|
||||
private capabilities: IEsclCapabilities | null = null;
|
||||
|
||||
constructor(address: string, port: number, secure: boolean = false) {
|
||||
const protocol = secure ? 'https' : 'http';
|
||||
this.baseUrl = `${protocol}://${address}:${port}/eSCL`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scanner capabilities
|
||||
*/
|
||||
public async getCapabilities(): Promise<IEsclCapabilities> {
|
||||
const response = await httpRequest(
|
||||
`${this.baseUrl}/ScannerCapabilities`,
|
||||
'GET'
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to get capabilities: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
this.capabilities = this.parseCapabilities(response.body);
|
||||
return this.capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scanner status
|
||||
*/
|
||||
public async getStatus(): Promise<IEsclScanStatus> {
|
||||
const response = await httpRequest(
|
||||
`${this.baseUrl}/ScannerStatus`,
|
||||
'GET'
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to get status: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return this.parseStatus(response.body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a scan job
|
||||
* Returns the job URI for tracking
|
||||
*/
|
||||
public async submitScanJob(options: IScanOptions): Promise<string> {
|
||||
const scanSettings = this.buildScanSettings(options);
|
||||
|
||||
// Use fetch for POST with body since SmartRequest API may not support raw body
|
||||
const response = await fetch(`${this.baseUrl}/ScanJobs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/xml; charset=utf-8',
|
||||
},
|
||||
body: scanSettings,
|
||||
});
|
||||
|
||||
if (response.status !== 201) {
|
||||
throw new Error(`Failed to submit scan job: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
// Get job URI from Location header
|
||||
const location = response.headers.get('location');
|
||||
if (!location) {
|
||||
throw new Error('No job location returned from scanner');
|
||||
}
|
||||
|
||||
return location;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for scan job to complete and download the result
|
||||
*/
|
||||
public async waitForScanComplete(
|
||||
jobUri: string,
|
||||
options: IScanOptions,
|
||||
pollInterval: number = 500
|
||||
): Promise<IScanResult> {
|
||||
// Poll until job is complete
|
||||
let attempts = 0;
|
||||
const maxAttempts = 120; // 60 seconds max
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
const status = await this.getStatus();
|
||||
const job = status.jobs?.find((j) => jobUri.includes(j.jobUuid) || j.jobUri === jobUri);
|
||||
|
||||
if (job) {
|
||||
if (job.jobState === 'Completed') {
|
||||
break;
|
||||
} else if (job.jobState === 'Canceled' || job.jobState === 'Aborted') {
|
||||
throw new Error(`Scan job ${job.jobState}: ${job.jobStateReason || 'Unknown reason'}`);
|
||||
}
|
||||
}
|
||||
|
||||
await plugins.smartdelay.delayFor(pollInterval);
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (attempts >= maxAttempts) {
|
||||
throw new Error('Scan job timed out');
|
||||
}
|
||||
|
||||
// Download the scanned document
|
||||
return this.downloadScan(jobUri, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download scanned document
|
||||
*/
|
||||
public async downloadScan(jobUri: string, options: IScanOptions): Promise<IScanResult> {
|
||||
const downloadUrl = `${jobUri}/NextDocument`;
|
||||
|
||||
const response = await fetch(downloadUrl, { method: 'GET' });
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to download scan: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const format = options.format ?? 'jpeg';
|
||||
const contentType = response.headers.get('content-type') ?? FORMAT_MIME_MAP[format];
|
||||
|
||||
// Get image dimensions from headers if available
|
||||
const width = parseInt(response.headers.get('x-image-width') || '0') || 0;
|
||||
const height = parseInt(response.headers.get('x-image-height') || '0') || 0;
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const data = Buffer.from(arrayBuffer);
|
||||
|
||||
return {
|
||||
data: data,
|
||||
format: format,
|
||||
width: width,
|
||||
height: height,
|
||||
resolution: options.resolution ?? 300,
|
||||
colorMode: options.colorMode ?? 'color',
|
||||
mimeType: contentType,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a scan job
|
||||
*/
|
||||
public async cancelJob(jobUri: string): Promise<void> {
|
||||
const response = await fetch(jobUri, { method: 'DELETE' });
|
||||
|
||||
// 204 No Content or 200 OK are both acceptable
|
||||
if (response.status !== 200 && response.status !== 204) {
|
||||
throw new Error(`Failed to cancel job: HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build XML scan settings
|
||||
*/
|
||||
private buildScanSettings(options: IScanOptions): string {
|
||||
const resolution = options.resolution ?? 300;
|
||||
const colorMode = COLOR_MODE_MAP[options.colorMode ?? 'color'];
|
||||
const format = FORMAT_MIME_MAP[options.format ?? 'jpeg'];
|
||||
const source = this.mapSource(options.source ?? 'flatbed');
|
||||
const intent = options.intent ?? 'TextAndPhoto';
|
||||
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<scan:ScanSettings xmlns:scan="${NAMESPACES.scan}" xmlns:pwg="${NAMESPACES.pwg}">
|
||||
<pwg:Version>2.0</pwg:Version>
|
||||
<scan:Intent>${intent}</scan:Intent>
|
||||
<pwg:ScanRegions>
|
||||
<pwg:ScanRegion>`;
|
||||
|
||||
if (options.area) {
|
||||
// Convert mm to 300ths of an inch (eSCL uses 300dpi as base unit)
|
||||
const toUnits = (mm: number) => Math.round((mm / 25.4) * 300);
|
||||
xml += `
|
||||
<pwg:XOffset>${toUnits(options.area.x)}</pwg:XOffset>
|
||||
<pwg:YOffset>${toUnits(options.area.y)}</pwg:YOffset>
|
||||
<pwg:Width>${toUnits(options.area.width)}</pwg:Width>
|
||||
<pwg:Height>${toUnits(options.area.height)}</pwg:Height>`;
|
||||
} else {
|
||||
// Full page (A4 default: 210x297mm)
|
||||
xml += `
|
||||
<pwg:XOffset>0</pwg:XOffset>
|
||||
<pwg:YOffset>0</pwg:YOffset>
|
||||
<pwg:Width>2480</pwg:Width>
|
||||
<pwg:Height>3508</pwg:Height>`;
|
||||
}
|
||||
|
||||
xml += `
|
||||
<pwg:ContentRegionUnits>escl:ThreeHundredthsOfInches</pwg:ContentRegionUnits>
|
||||
</pwg:ScanRegion>
|
||||
</pwg:ScanRegions>
|
||||
<scan:DocumentFormatExt>${format}</scan:DocumentFormatExt>
|
||||
<scan:XResolution>${resolution}</scan:XResolution>
|
||||
<scan:YResolution>${resolution}</scan:YResolution>
|
||||
<scan:ColorMode>${colorMode}</scan:ColorMode>
|
||||
<scan:InputSource>${source}</scan:InputSource>`;
|
||||
|
||||
if (options.format === 'jpeg' && options.quality) {
|
||||
xml += `
|
||||
<scan:CompressionFactor>${100 - options.quality}</scan:CompressionFactor>`;
|
||||
}
|
||||
|
||||
xml += `
|
||||
</scan:ScanSettings>`;
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map source to eSCL format
|
||||
*/
|
||||
private mapSource(source: string): string {
|
||||
switch (source) {
|
||||
case 'flatbed':
|
||||
return 'Platen';
|
||||
case 'adf':
|
||||
return 'Feeder';
|
||||
case 'adf-duplex':
|
||||
return 'Duplex';
|
||||
default:
|
||||
return 'Platen';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse capabilities XML response
|
||||
*/
|
||||
private parseCapabilities(body: string): IEsclCapabilities {
|
||||
const xml = body;
|
||||
|
||||
// Simple XML parsing without full XML parser
|
||||
const getTagContent = (tag: string): string => {
|
||||
const regex = new RegExp(`<[^:]*:?${tag}[^>]*>([^<]*)<`, 'i');
|
||||
const match = xml.match(regex);
|
||||
return match?.[1]?.trim() ?? '';
|
||||
};
|
||||
|
||||
const getAllTagContents = (tag: string): string[] => {
|
||||
const regex = new RegExp(`<[^:]*:?${tag}[^>]*>([^<]*)<`, 'gi');
|
||||
const matches: string[] = [];
|
||||
let match;
|
||||
while ((match = regex.exec(xml)) !== null) {
|
||||
if (match[1]?.trim()) {
|
||||
matches.push(match[1].trim());
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
};
|
||||
|
||||
const parseResolutions = (): number[] => {
|
||||
const resolutions = getAllTagContents('XResolution');
|
||||
return [...new Set(resolutions.map((r) => parseInt(r)).filter((r) => !isNaN(r)))];
|
||||
};
|
||||
|
||||
return {
|
||||
version: getTagContent('Version') || '2.0',
|
||||
makeAndModel: getTagContent('MakeAndModel') || getTagContent('Make') || 'Unknown',
|
||||
serialNumber: getTagContent('SerialNumber') || undefined,
|
||||
uuid: getTagContent('UUID') || undefined,
|
||||
adminUri: getTagContent('AdminURI') || undefined,
|
||||
iconUri: getTagContent('IconURI') || undefined,
|
||||
platen: xml.includes('Platen')
|
||||
? {
|
||||
minWidth: parseInt(getTagContent('MinWidth')) || 0,
|
||||
maxWidth: parseInt(getTagContent('MaxWidth')) || 2550,
|
||||
minHeight: parseInt(getTagContent('MinHeight')) || 0,
|
||||
maxHeight: parseInt(getTagContent('MaxHeight')) || 3508,
|
||||
maxScanRegions: parseInt(getTagContent('MaxScanRegions')) || 1,
|
||||
supportedResolutions: parseResolutions(),
|
||||
colorModes: getAllTagContents('ColorMode'),
|
||||
documentFormats: getAllTagContents('DocumentFormatExt'),
|
||||
}
|
||||
: undefined,
|
||||
adf: xml.includes('Adf') || xml.includes('ADF') || xml.includes('Feeder')
|
||||
? {
|
||||
minWidth: 0,
|
||||
maxWidth: 2550,
|
||||
minHeight: 0,
|
||||
maxHeight: 4200,
|
||||
maxScanRegions: 1,
|
||||
supportedResolutions: parseResolutions(),
|
||||
colorModes: getAllTagContents('ColorMode'),
|
||||
documentFormats: getAllTagContents('DocumentFormatExt'),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse status XML response
|
||||
*/
|
||||
private parseStatus(body: string): IEsclScanStatus {
|
||||
const xml = body;
|
||||
|
||||
const getTagContent = (tag: string): string => {
|
||||
const regex = new RegExp(`<[^:]*:?${tag}[^>]*>([^<]*)<`, 'i');
|
||||
const match = xml.match(regex);
|
||||
return match?.[1]?.trim() ?? '';
|
||||
};
|
||||
|
||||
const state = getTagContent('State') || getTagContent('ScannerState') || 'Idle';
|
||||
const adfState = getTagContent('AdfState') || undefined;
|
||||
|
||||
// Parse jobs if present
|
||||
const jobs: IEsclJobInfo[] = [];
|
||||
const jobMatches = xml.match(/<[^:]*:?JobInfo[^>]*>[\s\S]*?<\/[^:]*:?JobInfo>/gi);
|
||||
if (jobMatches) {
|
||||
for (const jobXml of jobMatches) {
|
||||
const getJobTag = (tag: string): string => {
|
||||
const regex = new RegExp(`<[^:]*:?${tag}[^>]*>([^<]*)<`, 'i');
|
||||
const match = jobXml.match(regex);
|
||||
return match?.[1]?.trim() ?? '';
|
||||
};
|
||||
|
||||
jobs.push({
|
||||
jobUri: getJobTag('JobUri') || getJobTag('JobURI') || '',
|
||||
jobUuid: getJobTag('JobUuid') || getJobTag('JobUUID') || '',
|
||||
age: parseInt(getJobTag('Age')) || 0,
|
||||
imagesCompleted: parseInt(getJobTag('ImagesCompleted')) || 0,
|
||||
imagesToTransfer: parseInt(getJobTag('ImagesToTransfer')) || 0,
|
||||
jobState: (getJobTag('JobState') as IEsclJobInfo['jobState']) || 'Pending',
|
||||
jobStateReason: getJobTag('JobStateReason') || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: state as IEsclScanStatus['state'],
|
||||
adfState: adfState as IEsclScanStatus['adfState'],
|
||||
jobs: jobs.length > 0 ? jobs : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a complete scan operation
|
||||
*/
|
||||
public async scan(options: IScanOptions): Promise<IScanResult> {
|
||||
// Submit the job
|
||||
const jobUri = await this.submitScanJob(options);
|
||||
|
||||
try {
|
||||
// Wait for completion and download
|
||||
return await this.waitForScanComplete(jobUri, options);
|
||||
} catch (error) {
|
||||
// Try to cancel the job on error
|
||||
try {
|
||||
await this.cancelJob(jobUri);
|
||||
} catch {
|
||||
// Ignore cancel errors
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
329
ts/protocols/protocol.ipp.ts
Normal file
329
ts/protocols/protocol.ipp.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type {
|
||||
IPrinterCapabilities,
|
||||
IPrintOptions,
|
||||
IPrintJob,
|
||||
} from '../interfaces/index.js';
|
||||
|
||||
/**
|
||||
* IPP protocol wrapper using the ipp npm package
|
||||
*/
|
||||
export class IppProtocol {
|
||||
private printerUrl: string;
|
||||
private printer: ReturnType<typeof plugins.ipp.Printer>;
|
||||
|
||||
constructor(address: string, port: number, path: string = '/ipp/print') {
|
||||
this.printerUrl = `ipp://${address}:${port}${path}`;
|
||||
this.printer = plugins.ipp.Printer(this.printerUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get printer attributes/capabilities
|
||||
*/
|
||||
public async getAttributes(): Promise<IPrinterCapabilities> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.printer.execute(
|
||||
'Get-Printer-Attributes',
|
||||
null,
|
||||
(err: Error | null, res: Record<string, unknown>) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const attrs = res['printer-attributes-tag'] as Record<string, unknown> || {};
|
||||
resolve(this.parseCapabilities(attrs));
|
||||
} catch (parseErr) {
|
||||
reject(parseErr);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a document
|
||||
*/
|
||||
public async print(data: Buffer, options?: IPrintOptions): Promise<IPrintJob> {
|
||||
const msg = this.buildPrintMessage(options);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.printer.execute(
|
||||
'Print-Job',
|
||||
{ ...msg, data },
|
||||
(err: Error | null, res: Record<string, unknown>) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const jobAttrs = res['job-attributes-tag'] as Record<string, unknown> || {};
|
||||
resolve(this.parseJobInfo(jobAttrs));
|
||||
} catch (parseErr) {
|
||||
reject(parseErr);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all jobs
|
||||
*/
|
||||
public async getJobs(): Promise<IPrintJob[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.printer.execute(
|
||||
'Get-Jobs',
|
||||
{
|
||||
'operation-attributes-tag': {
|
||||
'requesting-user-name': 'devicemanager',
|
||||
'which-jobs': 'not-completed',
|
||||
},
|
||||
},
|
||||
(err: Error | null, res: Record<string, unknown>) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const jobs: IPrintJob[] = [];
|
||||
const jobTags = res['job-attributes-tag'];
|
||||
|
||||
if (Array.isArray(jobTags)) {
|
||||
for (const jobAttrs of jobTags) {
|
||||
jobs.push(this.parseJobInfo(jobAttrs as Record<string, unknown>));
|
||||
}
|
||||
} else if (jobTags && typeof jobTags === 'object') {
|
||||
jobs.push(this.parseJobInfo(jobTags as Record<string, unknown>));
|
||||
}
|
||||
|
||||
resolve(jobs);
|
||||
} catch (parseErr) {
|
||||
reject(parseErr);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific job info
|
||||
*/
|
||||
public async getJobInfo(jobId: number): Promise<IPrintJob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.printer.execute(
|
||||
'Get-Job-Attributes',
|
||||
{
|
||||
'operation-attributes-tag': {
|
||||
'job-id': jobId,
|
||||
},
|
||||
},
|
||||
(err: Error | null, res: Record<string, unknown>) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const jobAttrs = res['job-attributes-tag'] as Record<string, unknown> || {};
|
||||
resolve(this.parseJobInfo(jobAttrs));
|
||||
} catch (parseErr) {
|
||||
reject(parseErr);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a job
|
||||
*/
|
||||
public async cancelJob(jobId: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.printer.execute(
|
||||
'Cancel-Job',
|
||||
{
|
||||
'operation-attributes-tag': {
|
||||
'job-id': jobId,
|
||||
},
|
||||
},
|
||||
(err: Error | null, _res: Record<string, unknown>) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if printer is available
|
||||
*/
|
||||
public async checkAvailability(): Promise<boolean> {
|
||||
try {
|
||||
await this.getAttributes();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build IPP print message from options
|
||||
*/
|
||||
private buildPrintMessage(options?: IPrintOptions): Record<string, unknown> {
|
||||
const operationAttrs: Record<string, unknown> = {
|
||||
'requesting-user-name': 'devicemanager',
|
||||
'job-name': options?.jobName ?? 'Print Job',
|
||||
'document-format': 'application/octet-stream',
|
||||
};
|
||||
|
||||
const jobAttrs: Record<string, unknown> = {};
|
||||
|
||||
if (options?.copies && options.copies > 1) {
|
||||
jobAttrs['copies'] = options.copies;
|
||||
}
|
||||
|
||||
if (options?.mediaSize) {
|
||||
jobAttrs['media'] = options.mediaSize;
|
||||
}
|
||||
|
||||
if (options?.mediaType) {
|
||||
jobAttrs['media-type'] = options.mediaType;
|
||||
}
|
||||
|
||||
if (options?.sides) {
|
||||
jobAttrs['sides'] = options.sides;
|
||||
}
|
||||
|
||||
if (options?.quality) {
|
||||
const qualityMap: Record<string, number> = {
|
||||
draft: 3,
|
||||
normal: 4,
|
||||
high: 5,
|
||||
};
|
||||
jobAttrs['print-quality'] = qualityMap[options.quality] ?? 4;
|
||||
}
|
||||
|
||||
if (options?.colorMode) {
|
||||
jobAttrs['print-color-mode'] = options.colorMode;
|
||||
}
|
||||
|
||||
const msg: Record<string, unknown> = {
|
||||
'operation-attributes-tag': operationAttrs,
|
||||
};
|
||||
|
||||
if (Object.keys(jobAttrs).length > 0) {
|
||||
msg['job-attributes-tag'] = jobAttrs;
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse printer capabilities from attributes
|
||||
*/
|
||||
private parseCapabilities(attrs: Record<string, unknown>): IPrinterCapabilities {
|
||||
const getArray = (key: string): string[] => {
|
||||
const value = attrs[key];
|
||||
if (Array.isArray(value)) return value.map(String);
|
||||
if (value !== undefined) return [String(value)];
|
||||
return [];
|
||||
};
|
||||
|
||||
const getNumber = (key: string, defaultVal: number): number => {
|
||||
const value = attrs[key];
|
||||
if (typeof value === 'number') return value;
|
||||
if (typeof value === 'string') return parseInt(value) || defaultVal;
|
||||
return defaultVal;
|
||||
};
|
||||
|
||||
const getBool = (key: string, defaultVal: boolean): boolean => {
|
||||
const value = attrs[key];
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === 'true' || value === 1) return true;
|
||||
if (value === 'false' || value === 0) return false;
|
||||
return defaultVal;
|
||||
};
|
||||
|
||||
// Parse resolutions
|
||||
const resolutions: number[] = [];
|
||||
const resSupported = attrs['printer-resolution-supported'];
|
||||
if (Array.isArray(resSupported)) {
|
||||
for (const res of resSupported) {
|
||||
if (typeof res === 'object' && res !== null && 'x' in res) {
|
||||
resolutions.push((res as { x: number }).x);
|
||||
} else if (typeof res === 'number') {
|
||||
resolutions.push(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (resolutions.length === 0) {
|
||||
resolutions.push(300, 600);
|
||||
}
|
||||
|
||||
return {
|
||||
colorSupported: getBool('color-supported', false),
|
||||
duplexSupported:
|
||||
getArray('sides-supported').some((s) =>
|
||||
s.includes('two-sided')
|
||||
),
|
||||
mediaSizes: getArray('media-supported'),
|
||||
mediaTypes: getArray('media-type-supported'),
|
||||
resolutions: [...new Set(resolutions)],
|
||||
maxCopies: getNumber('copies-supported', 99),
|
||||
sidesSupported: getArray('sides-supported'),
|
||||
qualitySupported: getArray('print-quality-supported').map(String),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse job info from attributes
|
||||
*/
|
||||
private parseJobInfo(attrs: Record<string, unknown>): IPrintJob {
|
||||
const getString = (key: string, defaultVal: string): string => {
|
||||
const value = attrs[key];
|
||||
if (typeof value === 'string') return value;
|
||||
if (value !== undefined) return String(value);
|
||||
return defaultVal;
|
||||
};
|
||||
|
||||
const getNumber = (key: string, defaultVal: number): number => {
|
||||
const value = attrs[key];
|
||||
if (typeof value === 'number') return value;
|
||||
if (typeof value === 'string') return parseInt(value) || defaultVal;
|
||||
return defaultVal;
|
||||
};
|
||||
|
||||
// Map IPP job state to our state
|
||||
const ippState = getNumber('job-state', 3);
|
||||
const stateMap: Record<number, IPrintJob['state']> = {
|
||||
3: 'pending', // pending
|
||||
4: 'pending', // pending-held
|
||||
5: 'processing', // processing
|
||||
6: 'processing', // processing-stopped
|
||||
7: 'canceled', // canceled
|
||||
8: 'aborted', // aborted
|
||||
9: 'completed', // completed
|
||||
};
|
||||
|
||||
const createdTime = attrs['time-at-creation'];
|
||||
const completedTime = attrs['time-at-completed'];
|
||||
|
||||
return {
|
||||
id: getNumber('job-id', 0),
|
||||
name: getString('job-name', 'Unknown Job'),
|
||||
state: stateMap[ippState] ?? 'pending',
|
||||
stateReason: getString('job-state-reasons', undefined),
|
||||
createdAt: createdTime ? new Date(createdTime as number * 1000) : new Date(),
|
||||
completedAt: completedTime ? new Date(completedTime as number * 1000) : undefined,
|
||||
pagesPrinted: getNumber('job-media-sheets-completed', undefined),
|
||||
pagesTotal: getNumber('job-media-sheets', undefined),
|
||||
};
|
||||
}
|
||||
}
|
||||
471
ts/protocols/protocol.nut.ts
Normal file
471
ts/protocols/protocol.nut.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
/**
|
||||
* NUT Protocol variable definitions
|
||||
*/
|
||||
export const NUT_VARIABLES = {
|
||||
// Device info
|
||||
deviceMfr: 'device.mfr',
|
||||
deviceModel: 'device.model',
|
||||
deviceSerial: 'device.serial',
|
||||
deviceType: 'device.type',
|
||||
|
||||
// UPS status
|
||||
upsStatus: 'ups.status',
|
||||
upsAlarm: 'ups.alarm',
|
||||
upsTime: 'ups.time',
|
||||
upsLoad: 'ups.load',
|
||||
upsTemperature: 'ups.temperature',
|
||||
|
||||
// Battery
|
||||
batteryCharge: 'battery.charge',
|
||||
batteryRuntime: 'battery.runtime',
|
||||
batteryVoltage: 'battery.voltage',
|
||||
batteryVoltageNominal: 'battery.voltage.nominal',
|
||||
batteryType: 'battery.type',
|
||||
batteryDate: 'battery.date',
|
||||
batteryTemperature: 'battery.temperature',
|
||||
|
||||
// Input
|
||||
inputVoltage: 'input.voltage',
|
||||
inputVoltageNominal: 'input.voltage.nominal',
|
||||
inputFrequency: 'input.frequency',
|
||||
inputFrequencyNominal: 'input.frequency.nominal',
|
||||
inputTransferHigh: 'input.transfer.high',
|
||||
inputTransferLow: 'input.transfer.low',
|
||||
|
||||
// Output
|
||||
outputVoltage: 'output.voltage',
|
||||
outputVoltageNominal: 'output.voltage.nominal',
|
||||
outputFrequency: 'output.frequency',
|
||||
outputCurrent: 'output.current',
|
||||
};
|
||||
|
||||
/**
|
||||
* NUT instant commands
|
||||
*/
|
||||
export const NUT_COMMANDS = {
|
||||
testBatteryStart: 'test.battery.start',
|
||||
testBatteryStartQuick: 'test.battery.start.quick',
|
||||
testBatteryStartDeep: 'test.battery.start.deep',
|
||||
testBatteryStop: 'test.battery.stop',
|
||||
calibrateStart: 'calibrate.start',
|
||||
calibrateStop: 'calibrate.stop',
|
||||
shutdown: 'shutdown.return',
|
||||
shutdownStayOff: 'shutdown.stayoff',
|
||||
shutdownStop: 'shutdown.stop',
|
||||
shutdownReboot: 'shutdown.reboot',
|
||||
beeperEnable: 'beeper.enable',
|
||||
beeperDisable: 'beeper.disable',
|
||||
beeperMute: 'beeper.mute',
|
||||
beeperToggle: 'beeper.toggle',
|
||||
loadOff: 'load.off',
|
||||
loadOn: 'load.on',
|
||||
};
|
||||
|
||||
/**
|
||||
* UPS status flags from NUT
|
||||
*/
|
||||
export type TNutStatusFlag =
|
||||
| 'OL' // Online (on utility power)
|
||||
| 'OB' // On battery
|
||||
| 'LB' // Low battery
|
||||
| 'HB' // High battery
|
||||
| 'RB' // Replace battery
|
||||
| 'CHRG' // Charging
|
||||
| 'DISCHRG' // Discharging
|
||||
| 'BYPASS' // On bypass
|
||||
| 'CAL' // Calibrating
|
||||
| 'OFF' // Offline
|
||||
| 'OVER' // Overloaded
|
||||
| 'TRIM' // Trimming voltage
|
||||
| 'BOOST' // Boosting voltage
|
||||
| 'FSD'; // Forced shutdown
|
||||
|
||||
/**
|
||||
* NUT UPS information
|
||||
*/
|
||||
export interface INutUpsInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* NUT variable
|
||||
*/
|
||||
export interface INutVariable {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* NUT Protocol handler for Network UPS Tools
|
||||
* TCP-based text protocol on port 3493
|
||||
*/
|
||||
export class NutProtocol {
|
||||
private socket: plugins.net.Socket | null = null;
|
||||
private address: string;
|
||||
private port: number;
|
||||
private connected: boolean = false;
|
||||
private responseBuffer: string = '';
|
||||
private responseResolver: ((value: string[]) => void) | null = null;
|
||||
private responseRejecter: ((error: Error) => void) | null = null;
|
||||
|
||||
constructor(address: string, port: number = 3493) {
|
||||
this.address = address;
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to NUT server
|
||||
*/
|
||||
public async connect(): Promise<void> {
|
||||
if (this.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.socket = new plugins.net.Socket();
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.socket) {
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
}
|
||||
reject(new Error(`Connection timeout to ${this.address}:${this.port}`));
|
||||
}, 5000);
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
clearTimeout(timeout);
|
||||
this.connected = true;
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.socket.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
this.connected = false;
|
||||
if (this.responseRejecter) {
|
||||
this.responseRejecter(err);
|
||||
this.responseRejecter = null;
|
||||
this.responseResolver = null;
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
|
||||
this.socket.on('data', (data: Buffer) => {
|
||||
this.handleData(data);
|
||||
});
|
||||
|
||||
this.socket.on('close', () => {
|
||||
this.connected = false;
|
||||
if (this.responseRejecter) {
|
||||
this.responseRejecter(new Error('Connection closed'));
|
||||
this.responseRejecter = null;
|
||||
this.responseResolver = null;
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.connect(this.port, this.address);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from NUT server
|
||||
*/
|
||||
public async disconnect(): Promise<void> {
|
||||
if (!this.connected || !this.socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.sendCommand('LOGOUT');
|
||||
} catch {
|
||||
// Ignore logout errors
|
||||
}
|
||||
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected
|
||||
*/
|
||||
public get isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming data
|
||||
*/
|
||||
private handleData(data: Buffer): void {
|
||||
this.responseBuffer += data.toString();
|
||||
|
||||
// Check for complete response (ends with newline)
|
||||
const lines = this.responseBuffer.split('\n');
|
||||
|
||||
// Check if we have a complete response
|
||||
if (this.responseBuffer.endsWith('\n')) {
|
||||
const responseLines = lines.filter((l) => l.trim().length > 0);
|
||||
this.responseBuffer = '';
|
||||
|
||||
if (this.responseResolver) {
|
||||
this.responseResolver(responseLines);
|
||||
this.responseResolver = null;
|
||||
this.responseRejecter = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send command and get response
|
||||
*/
|
||||
private async sendCommand(command: string): Promise<string[]> {
|
||||
if (!this.socket || !this.connected) {
|
||||
throw new Error('Not connected to NUT server');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.responseResolver = resolve;
|
||||
this.responseRejecter = reject;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
this.responseResolver = null;
|
||||
this.responseRejecter = null;
|
||||
reject(new Error(`Command timeout: ${command}`));
|
||||
}, 10000);
|
||||
|
||||
this.responseResolver = (lines) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(lines);
|
||||
};
|
||||
|
||||
this.responseRejecter = (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
};
|
||||
|
||||
this.socket!.write(`${command}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List available UPS devices
|
||||
*/
|
||||
public async listUps(): Promise<INutUpsInfo[]> {
|
||||
await this.ensureConnected();
|
||||
const response = await this.sendCommand('LIST UPS');
|
||||
|
||||
const upsList: INutUpsInfo[] = [];
|
||||
|
||||
for (const line of response) {
|
||||
// Format: UPS <name> "<description>"
|
||||
const match = line.match(/^UPS\s+(\S+)\s+"([^"]*)"/);
|
||||
if (match) {
|
||||
upsList.push({
|
||||
name: match[1],
|
||||
description: match[2],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return upsList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all variables for a UPS
|
||||
*/
|
||||
public async listVariables(upsName: string): Promise<INutVariable[]> {
|
||||
await this.ensureConnected();
|
||||
const response = await this.sendCommand(`LIST VAR ${upsName}`);
|
||||
|
||||
const variables: INutVariable[] = [];
|
||||
|
||||
for (const line of response) {
|
||||
// Format: VAR <ups> <name> "<value>"
|
||||
const match = line.match(/^VAR\s+\S+\s+(\S+)\s+"([^"]*)"/);
|
||||
if (match) {
|
||||
variables.push({
|
||||
name: match[1],
|
||||
value: match[2],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return variables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific variable value
|
||||
*/
|
||||
public async getVariable(upsName: string, varName: string): Promise<string | null> {
|
||||
await this.ensureConnected();
|
||||
const response = await this.sendCommand(`GET VAR ${upsName} ${varName}`);
|
||||
|
||||
for (const line of response) {
|
||||
// Format: VAR <ups> <name> "<value>"
|
||||
const match = line.match(/^VAR\s+\S+\s+\S+\s+"([^"]*)"/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
// Handle error responses
|
||||
if (line.startsWith('ERR')) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple variables at once
|
||||
*/
|
||||
public async getVariables(upsName: string, varNames: string[]): Promise<Map<string, string>> {
|
||||
const results = new Map<string, string>();
|
||||
|
||||
for (const varName of varNames) {
|
||||
const value = await this.getVariable(upsName, varName);
|
||||
if (value !== null) {
|
||||
results.set(varName, value);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an instant command
|
||||
*/
|
||||
public async runCommand(upsName: string, command: string): Promise<boolean> {
|
||||
await this.ensureConnected();
|
||||
const response = await this.sendCommand(`INSTCMD ${upsName} ${command}`);
|
||||
|
||||
for (const line of response) {
|
||||
if (line === 'OK') {
|
||||
return true;
|
||||
}
|
||||
if (line.startsWith('ERR')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* List available commands for a UPS
|
||||
*/
|
||||
public async listCommands(upsName: string): Promise<string[]> {
|
||||
await this.ensureConnected();
|
||||
const response = await this.sendCommand(`LIST CMD ${upsName}`);
|
||||
|
||||
const commands: string[] = [];
|
||||
|
||||
for (const line of response) {
|
||||
// Format: CMD <ups> <command>
|
||||
const match = line.match(/^CMD\s+\S+\s+(\S+)/);
|
||||
if (match) {
|
||||
commands.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse UPS status string into flags
|
||||
*/
|
||||
public parseStatus(statusString: string): TNutStatusFlag[] {
|
||||
return statusString.split(/\s+/).filter((s) => s.length > 0) as TNutStatusFlag[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive UPS status
|
||||
*/
|
||||
public async getUpsStatus(upsName: string): Promise<{
|
||||
status: TNutStatusFlag[];
|
||||
batteryCharge: number;
|
||||
batteryRuntime: number;
|
||||
inputVoltage: number;
|
||||
outputVoltage: number;
|
||||
load: number;
|
||||
}> {
|
||||
const vars = await this.getVariables(upsName, [
|
||||
NUT_VARIABLES.upsStatus,
|
||||
NUT_VARIABLES.batteryCharge,
|
||||
NUT_VARIABLES.batteryRuntime,
|
||||
NUT_VARIABLES.inputVoltage,
|
||||
NUT_VARIABLES.outputVoltage,
|
||||
NUT_VARIABLES.upsLoad,
|
||||
]);
|
||||
|
||||
return {
|
||||
status: this.parseStatus(vars.get(NUT_VARIABLES.upsStatus) || ''),
|
||||
batteryCharge: parseFloat(vars.get(NUT_VARIABLES.batteryCharge) || '0'),
|
||||
batteryRuntime: parseFloat(vars.get(NUT_VARIABLES.batteryRuntime) || '0'),
|
||||
inputVoltage: parseFloat(vars.get(NUT_VARIABLES.inputVoltage) || '0'),
|
||||
outputVoltage: parseFloat(vars.get(NUT_VARIABLES.outputVoltage) || '0'),
|
||||
load: parseFloat(vars.get(NUT_VARIABLES.upsLoad) || '0'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device information
|
||||
*/
|
||||
public async getDeviceInfo(upsName: string): Promise<{
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
serial: string;
|
||||
type: string;
|
||||
}> {
|
||||
const vars = await this.getVariables(upsName, [
|
||||
NUT_VARIABLES.deviceMfr,
|
||||
NUT_VARIABLES.deviceModel,
|
||||
NUT_VARIABLES.deviceSerial,
|
||||
NUT_VARIABLES.deviceType,
|
||||
]);
|
||||
|
||||
return {
|
||||
manufacturer: vars.get(NUT_VARIABLES.deviceMfr) || '',
|
||||
model: vars.get(NUT_VARIABLES.deviceModel) || '',
|
||||
serial: vars.get(NUT_VARIABLES.deviceSerial) || '',
|
||||
type: vars.get(NUT_VARIABLES.deviceType) || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure connected before command
|
||||
*/
|
||||
private async ensureConnected(): Promise<void> {
|
||||
if (!this.connected) {
|
||||
await this.connect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a NUT server is reachable
|
||||
*/
|
||||
public static async probe(address: string, port: number = 3493, timeout: number = 3000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new plugins.net.Socket();
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
}, timeout);
|
||||
|
||||
socket.on('connect', () => {
|
||||
clearTimeout(timer);
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
socket.on('error', () => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
socket.connect(port, address);
|
||||
});
|
||||
}
|
||||
}
|
||||
694
ts/protocols/protocol.sane.ts
Normal file
694
ts/protocols/protocol.sane.ts
Normal file
@@ -0,0 +1,694 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
439
ts/protocols/protocol.snmp.ts
Normal file
439
ts/protocols/protocol.snmp.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
/**
|
||||
* Common SNMP OIDs (Object Identifiers)
|
||||
*/
|
||||
export const SNMP_OIDS = {
|
||||
// System MIB (RFC 1213)
|
||||
sysDescr: '1.3.6.1.2.1.1.1.0',
|
||||
sysObjectID: '1.3.6.1.2.1.1.2.0',
|
||||
sysUpTime: '1.3.6.1.2.1.1.3.0',
|
||||
sysContact: '1.3.6.1.2.1.1.4.0',
|
||||
sysName: '1.3.6.1.2.1.1.5.0',
|
||||
sysLocation: '1.3.6.1.2.1.1.6.0',
|
||||
sysServices: '1.3.6.1.2.1.1.7.0',
|
||||
|
||||
// IF-MIB - Interfaces
|
||||
ifNumber: '1.3.6.1.2.1.2.1.0',
|
||||
ifTable: '1.3.6.1.2.1.2.2',
|
||||
|
||||
// Host resources
|
||||
hrSystemUptime: '1.3.6.1.2.1.25.1.1.0',
|
||||
hrMemorySize: '1.3.6.1.2.1.25.2.2.0',
|
||||
|
||||
// UPS-MIB (RFC 1628)
|
||||
upsIdentManufacturer: '1.3.6.1.2.1.33.1.1.1.0',
|
||||
upsIdentModel: '1.3.6.1.2.1.33.1.1.2.0',
|
||||
upsBatteryStatus: '1.3.6.1.2.1.33.1.2.1.0',
|
||||
upsSecondsOnBattery: '1.3.6.1.2.1.33.1.2.2.0',
|
||||
upsEstimatedMinutesRemaining: '1.3.6.1.2.1.33.1.2.3.0',
|
||||
upsEstimatedChargeRemaining: '1.3.6.1.2.1.33.1.2.4.0',
|
||||
upsBatteryVoltage: '1.3.6.1.2.1.33.1.2.5.0',
|
||||
upsInputFrequency: '1.3.6.1.2.1.33.1.3.3.1.2',
|
||||
upsInputVoltage: '1.3.6.1.2.1.33.1.3.3.1.3',
|
||||
upsOutputSource: '1.3.6.1.2.1.33.1.4.1.0',
|
||||
upsOutputFrequency: '1.3.6.1.2.1.33.1.4.2.0',
|
||||
upsOutputVoltage: '1.3.6.1.2.1.33.1.4.4.1.2',
|
||||
upsOutputCurrent: '1.3.6.1.2.1.33.1.4.4.1.3',
|
||||
upsOutputPower: '1.3.6.1.2.1.33.1.4.4.1.4',
|
||||
upsOutputPercentLoad: '1.3.6.1.2.1.33.1.4.4.1.5',
|
||||
|
||||
// Printer MIB
|
||||
prtGeneralPrinterName: '1.3.6.1.2.1.43.5.1.1.16.1',
|
||||
prtMarkerSuppliesLevel: '1.3.6.1.2.1.43.11.1.1.9',
|
||||
prtMarkerSuppliesMaxCapacity: '1.3.6.1.2.1.43.11.1.1.8',
|
||||
};
|
||||
|
||||
/**
|
||||
* SNMP value types
|
||||
*/
|
||||
export type TSnmpValueType =
|
||||
| 'OctetString'
|
||||
| 'Integer'
|
||||
| 'Counter'
|
||||
| 'Counter32'
|
||||
| 'Counter64'
|
||||
| 'Gauge'
|
||||
| 'Gauge32'
|
||||
| 'TimeTicks'
|
||||
| 'IpAddress'
|
||||
| 'ObjectIdentifier'
|
||||
| 'Null'
|
||||
| 'Opaque';
|
||||
|
||||
/**
|
||||
* SNMP varbind (variable binding)
|
||||
*/
|
||||
export interface ISnmpVarbind {
|
||||
oid: string;
|
||||
type: TSnmpValueType;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* SNMP session options
|
||||
*/
|
||||
export interface ISnmpOptions {
|
||||
/** Community string (v1/v2c) or username (v3) */
|
||||
community?: string;
|
||||
/** SNMP version: 1, 2 (v2c), or 3 */
|
||||
version?: 1 | 2 | 3;
|
||||
/** Request timeout in milliseconds */
|
||||
timeout?: number;
|
||||
/** Number of retries */
|
||||
retries?: number;
|
||||
/** Port (default: 161) */
|
||||
port?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<ISnmpOptions> = {
|
||||
community: 'public',
|
||||
version: 2,
|
||||
timeout: 5000,
|
||||
retries: 1,
|
||||
port: 161,
|
||||
};
|
||||
|
||||
/**
|
||||
* SNMP Protocol handler using net-snmp
|
||||
*/
|
||||
export class SnmpProtocol {
|
||||
private session: ReturnType<typeof plugins.netSnmp.createSession> | null = null;
|
||||
private address: string;
|
||||
private options: Required<ISnmpOptions>;
|
||||
|
||||
constructor(address: string, options?: ISnmpOptions) {
|
||||
this.address = address;
|
||||
this.options = { ...DEFAULT_OPTIONS, ...options };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SNMP session
|
||||
*/
|
||||
private getSession(): ReturnType<typeof plugins.netSnmp.createSession> {
|
||||
if (!this.session) {
|
||||
const snmpVersion =
|
||||
this.options.version === 1
|
||||
? plugins.netSnmp.Version1
|
||||
: plugins.netSnmp.Version2c;
|
||||
|
||||
this.session = plugins.netSnmp.createSession(this.address, this.options.community, {
|
||||
port: this.options.port,
|
||||
retries: this.options.retries,
|
||||
timeout: this.options.timeout,
|
||||
version: snmpVersion,
|
||||
});
|
||||
}
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close SNMP session
|
||||
*/
|
||||
public close(): void {
|
||||
if (this.session) {
|
||||
this.session.close();
|
||||
this.session = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET operation - retrieve a single OID value
|
||||
*/
|
||||
public async get(oid: string): Promise<ISnmpVarbind> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const session = this.getSession();
|
||||
|
||||
session.get([oid], (error: Error | null, varbinds: unknown[]) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (varbinds.length === 0) {
|
||||
reject(new Error(`No response for OID ${oid}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const vb = varbinds[0] as { oid: string; type: number; value: unknown };
|
||||
|
||||
if (plugins.netSnmp.isVarbindError(vb)) {
|
||||
reject(new Error(`SNMP error for ${oid}: ${plugins.netSnmp.varbindError(vb)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(this.parseVarbind(vb));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET operation - retrieve multiple OID values
|
||||
*/
|
||||
public async getMultiple(oids: string[]): Promise<ISnmpVarbind[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const session = this.getSession();
|
||||
|
||||
session.get(oids, (error: Error | null, varbinds: unknown[]) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const results: ISnmpVarbind[] = [];
|
||||
for (const vb of varbinds) {
|
||||
const varbind = vb as { oid: string; type: number; value: unknown };
|
||||
if (!plugins.netSnmp.isVarbindError(varbind)) {
|
||||
results.push(this.parseVarbind(varbind));
|
||||
}
|
||||
}
|
||||
|
||||
resolve(results);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GETNEXT operation - get the next OID in the MIB tree
|
||||
*/
|
||||
public async getNext(oid: string): Promise<ISnmpVarbind> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const session = this.getSession();
|
||||
|
||||
session.getNext([oid], (error: Error | null, varbinds: unknown[]) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (varbinds.length === 0) {
|
||||
reject(new Error(`No response for GETNEXT ${oid}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const vb = varbinds[0] as { oid: string; type: number; value: unknown };
|
||||
|
||||
if (plugins.netSnmp.isVarbindError(vb)) {
|
||||
reject(new Error(`SNMP error for ${oid}: ${plugins.netSnmp.varbindError(vb)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(this.parseVarbind(vb));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GETBULK operation (v2c/v3 only) - efficient retrieval of table rows
|
||||
*/
|
||||
public async getBulk(
|
||||
oids: string[],
|
||||
nonRepeaters: number = 0,
|
||||
maxRepetitions: number = 20
|
||||
): Promise<ISnmpVarbind[]> {
|
||||
if (this.options.version === 1) {
|
||||
throw new Error('GETBULK is not supported in SNMPv1');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const session = this.getSession();
|
||||
|
||||
session.getBulk(oids, nonRepeaters, maxRepetitions, (error: Error | null, varbinds: unknown[]) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const results: ISnmpVarbind[] = [];
|
||||
for (const vb of varbinds) {
|
||||
const varbind = vb as { oid: string; type: number; value: unknown };
|
||||
if (!plugins.netSnmp.isVarbindError(varbind)) {
|
||||
results.push(this.parseVarbind(varbind));
|
||||
}
|
||||
}
|
||||
|
||||
resolve(results);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk operation - retrieve all OIDs under a tree
|
||||
*/
|
||||
public async walk(baseOid: string): Promise<ISnmpVarbind[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const session = this.getSession();
|
||||
const results: ISnmpVarbind[] = [];
|
||||
|
||||
session.walk(
|
||||
baseOid,
|
||||
20, // maxRepetitions
|
||||
(varbinds: unknown[]) => {
|
||||
for (const vb of varbinds) {
|
||||
const varbind = vb as { oid: string; type: number; value: unknown };
|
||||
if (!plugins.netSnmp.isVarbindError(varbind)) {
|
||||
results.push(this.parseVarbind(varbind));
|
||||
}
|
||||
}
|
||||
},
|
||||
(error: Error | null) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(results);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SET operation - set an OID value
|
||||
*/
|
||||
public async set(oid: string, type: TSnmpValueType, value: unknown): Promise<ISnmpVarbind> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const session = this.getSession();
|
||||
|
||||
const snmpType = this.getSnmpType(type);
|
||||
const varbind = {
|
||||
oid,
|
||||
type: snmpType,
|
||||
value,
|
||||
};
|
||||
|
||||
session.set([varbind], (error: Error | null, varbinds: unknown[]) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (varbinds.length === 0) {
|
||||
reject(new Error(`No response for SET ${oid}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const vb = varbinds[0] as { oid: string; type: number; value: unknown };
|
||||
|
||||
if (plugins.netSnmp.isVarbindError(vb)) {
|
||||
reject(new Error(`SNMP error for ${oid}: ${plugins.netSnmp.varbindError(vb)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(this.parseVarbind(vb));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system information
|
||||
*/
|
||||
public async getSystemInfo(): Promise<{
|
||||
sysDescr: string;
|
||||
sysObjectID: string;
|
||||
sysUpTime: number;
|
||||
sysContact: string;
|
||||
sysName: string;
|
||||
sysLocation: string;
|
||||
}> {
|
||||
const oids = [
|
||||
SNMP_OIDS.sysDescr,
|
||||
SNMP_OIDS.sysObjectID,
|
||||
SNMP_OIDS.sysUpTime,
|
||||
SNMP_OIDS.sysContact,
|
||||
SNMP_OIDS.sysName,
|
||||
SNMP_OIDS.sysLocation,
|
||||
];
|
||||
|
||||
const varbinds = await this.getMultiple(oids);
|
||||
|
||||
const getValue = (oid: string): unknown => {
|
||||
const vb = varbinds.find((v) => v.oid === oid);
|
||||
return vb?.value;
|
||||
};
|
||||
|
||||
return {
|
||||
sysDescr: String(getValue(SNMP_OIDS.sysDescr) || ''),
|
||||
sysObjectID: String(getValue(SNMP_OIDS.sysObjectID) || ''),
|
||||
sysUpTime: Number(getValue(SNMP_OIDS.sysUpTime) || 0),
|
||||
sysContact: String(getValue(SNMP_OIDS.sysContact) || ''),
|
||||
sysName: String(getValue(SNMP_OIDS.sysName) || ''),
|
||||
sysLocation: String(getValue(SNMP_OIDS.sysLocation) || ''),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if device is reachable via SNMP
|
||||
*/
|
||||
public async isReachable(): Promise<boolean> {
|
||||
try {
|
||||
await this.get(SNMP_OIDS.sysDescr);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse varbind to our format
|
||||
*/
|
||||
private parseVarbind(vb: { oid: string; type: number; value: unknown }): ISnmpVarbind {
|
||||
return {
|
||||
oid: vb.oid,
|
||||
type: this.getTypeName(vb.type),
|
||||
value: this.parseValue(vb.type, vb.value),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get type name from SNMP type number
|
||||
*/
|
||||
private getTypeName(type: number): TSnmpValueType {
|
||||
const typeMap: Record<number, TSnmpValueType> = {
|
||||
[plugins.netSnmp.ObjectType.OctetString]: 'OctetString',
|
||||
[plugins.netSnmp.ObjectType.Integer]: 'Integer',
|
||||
[plugins.netSnmp.ObjectType.Counter]: 'Counter',
|
||||
[plugins.netSnmp.ObjectType.Counter32]: 'Counter32',
|
||||
[plugins.netSnmp.ObjectType.Counter64]: 'Counter64',
|
||||
[plugins.netSnmp.ObjectType.Gauge]: 'Gauge',
|
||||
[plugins.netSnmp.ObjectType.Gauge32]: 'Gauge32',
|
||||
[plugins.netSnmp.ObjectType.TimeTicks]: 'TimeTicks',
|
||||
[plugins.netSnmp.ObjectType.IpAddress]: 'IpAddress',
|
||||
[plugins.netSnmp.ObjectType.OID]: 'ObjectIdentifier',
|
||||
[plugins.netSnmp.ObjectType.Null]: 'Null',
|
||||
[plugins.netSnmp.ObjectType.Opaque]: 'Opaque',
|
||||
};
|
||||
return typeMap[type] || 'OctetString';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SNMP type number from type name
|
||||
*/
|
||||
private getSnmpType(type: TSnmpValueType): number {
|
||||
const typeMap: Record<TSnmpValueType, number> = {
|
||||
OctetString: plugins.netSnmp.ObjectType.OctetString,
|
||||
Integer: plugins.netSnmp.ObjectType.Integer,
|
||||
Counter: plugins.netSnmp.ObjectType.Counter,
|
||||
Counter32: plugins.netSnmp.ObjectType.Counter32,
|
||||
Counter64: plugins.netSnmp.ObjectType.Counter64,
|
||||
Gauge: plugins.netSnmp.ObjectType.Gauge,
|
||||
Gauge32: plugins.netSnmp.ObjectType.Gauge32,
|
||||
TimeTicks: plugins.netSnmp.ObjectType.TimeTicks,
|
||||
IpAddress: plugins.netSnmp.ObjectType.IpAddress,
|
||||
ObjectIdentifier: plugins.netSnmp.ObjectType.OID,
|
||||
Null: plugins.netSnmp.ObjectType.Null,
|
||||
Opaque: plugins.netSnmp.ObjectType.Opaque,
|
||||
};
|
||||
return typeMap[type];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse value based on type
|
||||
*/
|
||||
private parseValue(type: number, value: unknown): unknown {
|
||||
// OctetString - convert Buffer to string
|
||||
if (type === plugins.netSnmp.ObjectType.OctetString && Buffer.isBuffer(value)) {
|
||||
return value.toString('utf8');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
627
ts/protocols/protocol.upnp.ts
Normal file
627
ts/protocols/protocol.upnp.ts
Normal file
@@ -0,0 +1,627 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
/**
|
||||
* UPnP service types for DLNA
|
||||
*/
|
||||
export const UPNP_SERVICE_TYPES = {
|
||||
AVTransport: 'urn:schemas-upnp-org:service:AVTransport:1',
|
||||
RenderingControl: 'urn:schemas-upnp-org:service:RenderingControl:1',
|
||||
ConnectionManager: 'urn:schemas-upnp-org:service:ConnectionManager:1',
|
||||
ContentDirectory: 'urn:schemas-upnp-org:service:ContentDirectory:1',
|
||||
};
|
||||
|
||||
/**
|
||||
* UPnP device types for DLNA
|
||||
*/
|
||||
export const UPNP_DEVICE_TYPES = {
|
||||
MediaRenderer: 'urn:schemas-upnp-org:device:MediaRenderer:1',
|
||||
MediaServer: 'urn:schemas-upnp-org:device:MediaServer:1',
|
||||
};
|
||||
|
||||
/**
|
||||
* DLNA transport state
|
||||
*/
|
||||
export type TDlnaTransportState =
|
||||
| 'STOPPED'
|
||||
| 'PLAYING'
|
||||
| 'PAUSED_PLAYBACK'
|
||||
| 'TRANSITIONING'
|
||||
| 'NO_MEDIA_PRESENT';
|
||||
|
||||
/**
|
||||
* DLNA transport status
|
||||
*/
|
||||
export type TDlnaTransportStatus = 'OK' | 'ERROR_OCCURRED';
|
||||
|
||||
/**
|
||||
* Position info from AVTransport
|
||||
*/
|
||||
export interface IDlnaPositionInfo {
|
||||
track: number;
|
||||
trackDuration: string;
|
||||
trackMetadata: string;
|
||||
trackUri: string;
|
||||
relativeTime: string;
|
||||
absoluteTime: string;
|
||||
relativeCount: number;
|
||||
absoluteCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport info from AVTransport
|
||||
*/
|
||||
export interface IDlnaTransportInfo {
|
||||
state: TDlnaTransportState;
|
||||
status: TDlnaTransportStatus;
|
||||
speed: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Media info from AVTransport
|
||||
*/
|
||||
export interface IDlnaMediaInfo {
|
||||
nrTracks: number;
|
||||
mediaDuration: string;
|
||||
currentUri: string;
|
||||
currentUriMetadata: string;
|
||||
nextUri: string;
|
||||
nextUriMetadata: string;
|
||||
playMedium: string;
|
||||
recordMedium: string;
|
||||
writeStatus: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content item from ContentDirectory
|
||||
*/
|
||||
export interface IDlnaContentItem {
|
||||
id: string;
|
||||
parentId: string;
|
||||
title: string;
|
||||
class: string;
|
||||
restricted: boolean;
|
||||
res?: {
|
||||
url: string;
|
||||
protocolInfo: string;
|
||||
size?: number;
|
||||
duration?: string;
|
||||
resolution?: string;
|
||||
bitrate?: number;
|
||||
}[];
|
||||
albumArtUri?: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
genre?: string;
|
||||
date?: string;
|
||||
childCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse result from ContentDirectory
|
||||
*/
|
||||
export interface IDlnaBrowseResult {
|
||||
items: IDlnaContentItem[];
|
||||
numberReturned: number;
|
||||
totalMatches: number;
|
||||
updateId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* UPnP SOAP client for DLNA operations
|
||||
*/
|
||||
export class UpnpSoapClient {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a SOAP request to a UPnP service
|
||||
*/
|
||||
public async soapAction(
|
||||
controlUrl: string,
|
||||
serviceType: string,
|
||||
action: string,
|
||||
args: Record<string, string | number> = {}
|
||||
): Promise<string> {
|
||||
// Build SOAP body
|
||||
let argsXml = '';
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
const escapedValue = this.escapeXml(String(value));
|
||||
argsXml += `<${key}>${escapedValue}</${key}>`;
|
||||
}
|
||||
|
||||
const soapBody = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<u:${action} xmlns:u="${serviceType}">
|
||||
${argsXml}
|
||||
</u:${action}>
|
||||
</s:Body>
|
||||
</s:Envelope>`;
|
||||
|
||||
const fullUrl = controlUrl.startsWith('http') ? controlUrl : `${this.baseUrl}${controlUrl}`;
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/xml; charset=utf-8',
|
||||
'SOAPACTION': `"${serviceType}#${action}"`,
|
||||
},
|
||||
body: soapBody,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`SOAP request failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape XML special characters
|
||||
*/
|
||||
private escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Unescape XML special characters
|
||||
*/
|
||||
public unescapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract value from SOAP response
|
||||
*/
|
||||
public extractValue(xml: string, tag: string): string {
|
||||
const regex = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i');
|
||||
const match = xml.match(regex);
|
||||
return match ? match[1].trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract multiple values from SOAP response
|
||||
*/
|
||||
public extractValues(xml: string, tags: string[]): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const tag of tags) {
|
||||
result[tag] = this.extractValue(xml, tag);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AVTransport Actions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Set the URI to play
|
||||
*/
|
||||
public async setAVTransportURI(
|
||||
controlUrl: string,
|
||||
uri: string,
|
||||
metadata: string = ''
|
||||
): Promise<void> {
|
||||
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'SetAVTransportURI', {
|
||||
InstanceID: 0,
|
||||
CurrentURI: uri,
|
||||
CurrentURIMetaData: metadata,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set next URI to play
|
||||
*/
|
||||
public async setNextAVTransportURI(
|
||||
controlUrl: string,
|
||||
uri: string,
|
||||
metadata: string = ''
|
||||
): Promise<void> {
|
||||
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'SetNextAVTransportURI', {
|
||||
InstanceID: 0,
|
||||
NextURI: uri,
|
||||
NextURIMetaData: metadata,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Play
|
||||
*/
|
||||
public async play(controlUrl: string, speed: string = '1'): Promise<void> {
|
||||
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Play', {
|
||||
InstanceID: 0,
|
||||
Speed: speed,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause
|
||||
*/
|
||||
public async pause(controlUrl: string): Promise<void> {
|
||||
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Pause', {
|
||||
InstanceID: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop
|
||||
*/
|
||||
public async stop(controlUrl: string): Promise<void> {
|
||||
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Stop', {
|
||||
InstanceID: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek
|
||||
*/
|
||||
public async seek(controlUrl: string, target: string, unit: string = 'REL_TIME'): Promise<void> {
|
||||
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Seek', {
|
||||
InstanceID: 0,
|
||||
Unit: unit,
|
||||
Target: target,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Next track
|
||||
*/
|
||||
public async next(controlUrl: string): Promise<void> {
|
||||
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Next', {
|
||||
InstanceID: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Previous track
|
||||
*/
|
||||
public async previous(controlUrl: string): Promise<void> {
|
||||
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'Previous', {
|
||||
InstanceID: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get position info
|
||||
*/
|
||||
public async getPositionInfo(controlUrl: string): Promise<IDlnaPositionInfo> {
|
||||
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'GetPositionInfo', {
|
||||
InstanceID: 0,
|
||||
});
|
||||
|
||||
const values = this.extractValues(response, [
|
||||
'Track', 'TrackDuration', 'TrackMetaData', 'TrackURI',
|
||||
'RelTime', 'AbsTime', 'RelCount', 'AbsCount',
|
||||
]);
|
||||
|
||||
return {
|
||||
track: parseInt(values['Track']) || 0,
|
||||
trackDuration: values['TrackDuration'] || '0:00:00',
|
||||
trackMetadata: this.unescapeXml(values['TrackMetaData'] || ''),
|
||||
trackUri: values['TrackURI'] || '',
|
||||
relativeTime: values['RelTime'] || '0:00:00',
|
||||
absoluteTime: values['AbsTime'] || 'NOT_IMPLEMENTED',
|
||||
relativeCount: parseInt(values['RelCount']) || 0,
|
||||
absoluteCount: parseInt(values['AbsCount']) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transport info
|
||||
*/
|
||||
public async getTransportInfo(controlUrl: string): Promise<IDlnaTransportInfo> {
|
||||
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'GetTransportInfo', {
|
||||
InstanceID: 0,
|
||||
});
|
||||
|
||||
const values = this.extractValues(response, [
|
||||
'CurrentTransportState', 'CurrentTransportStatus', 'CurrentSpeed',
|
||||
]);
|
||||
|
||||
return {
|
||||
state: (values['CurrentTransportState'] || 'STOPPED') as TDlnaTransportState,
|
||||
status: (values['CurrentTransportStatus'] || 'OK') as TDlnaTransportStatus,
|
||||
speed: values['CurrentSpeed'] || '1',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get media info
|
||||
*/
|
||||
public async getMediaInfo(controlUrl: string): Promise<IDlnaMediaInfo> {
|
||||
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.AVTransport, 'GetMediaInfo', {
|
||||
InstanceID: 0,
|
||||
});
|
||||
|
||||
const values = this.extractValues(response, [
|
||||
'NrTracks', 'MediaDuration', 'CurrentURI', 'CurrentURIMetaData',
|
||||
'NextURI', 'NextURIMetaData', 'PlayMedium', 'RecordMedium', 'WriteStatus',
|
||||
]);
|
||||
|
||||
return {
|
||||
nrTracks: parseInt(values['NrTracks']) || 0,
|
||||
mediaDuration: values['MediaDuration'] || '0:00:00',
|
||||
currentUri: values['CurrentURI'] || '',
|
||||
currentUriMetadata: this.unescapeXml(values['CurrentURIMetaData'] || ''),
|
||||
nextUri: values['NextURI'] || '',
|
||||
nextUriMetadata: this.unescapeXml(values['NextURIMetaData'] || ''),
|
||||
playMedium: values['PlayMedium'] || 'NONE',
|
||||
recordMedium: values['RecordMedium'] || 'NOT_IMPLEMENTED',
|
||||
writeStatus: values['WriteStatus'] || 'NOT_IMPLEMENTED',
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RenderingControl Actions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get volume
|
||||
*/
|
||||
public async getVolume(controlUrl: string, channel: string = 'Master'): Promise<number> {
|
||||
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'GetVolume', {
|
||||
InstanceID: 0,
|
||||
Channel: channel,
|
||||
});
|
||||
|
||||
const volume = this.extractValue(response, 'CurrentVolume');
|
||||
return parseInt(volume) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set volume
|
||||
*/
|
||||
public async setVolume(controlUrl: string, volume: number, channel: string = 'Master'): Promise<void> {
|
||||
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'SetVolume', {
|
||||
InstanceID: 0,
|
||||
Channel: channel,
|
||||
DesiredVolume: Math.max(0, Math.min(100, volume)),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mute state
|
||||
*/
|
||||
public async getMute(controlUrl: string, channel: string = 'Master'): Promise<boolean> {
|
||||
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'GetMute', {
|
||||
InstanceID: 0,
|
||||
Channel: channel,
|
||||
});
|
||||
|
||||
const mute = this.extractValue(response, 'CurrentMute');
|
||||
return mute === '1' || mute.toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set mute state
|
||||
*/
|
||||
public async setMute(controlUrl: string, muted: boolean, channel: string = 'Master'): Promise<void> {
|
||||
await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.RenderingControl, 'SetMute', {
|
||||
InstanceID: 0,
|
||||
Channel: channel,
|
||||
DesiredMute: muted ? 1 : 0,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ContentDirectory Actions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Browse content directory
|
||||
*/
|
||||
public async browse(
|
||||
controlUrl: string,
|
||||
objectId: string = '0',
|
||||
browseFlag: 'BrowseDirectChildren' | 'BrowseMetadata' = 'BrowseDirectChildren',
|
||||
filter: string = '*',
|
||||
startIndex: number = 0,
|
||||
requestCount: number = 100,
|
||||
sortCriteria: string = ''
|
||||
): Promise<IDlnaBrowseResult> {
|
||||
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.ContentDirectory, 'Browse', {
|
||||
ObjectID: objectId,
|
||||
BrowseFlag: browseFlag,
|
||||
Filter: filter,
|
||||
StartingIndex: startIndex,
|
||||
RequestedCount: requestCount,
|
||||
SortCriteria: sortCriteria,
|
||||
});
|
||||
|
||||
const values = this.extractValues(response, ['Result', 'NumberReturned', 'TotalMatches', 'UpdateID']);
|
||||
|
||||
const resultXml = this.unescapeXml(values['Result'] || '');
|
||||
const items = this.parseDidlResult(resultXml);
|
||||
|
||||
return {
|
||||
items,
|
||||
numberReturned: parseInt(values['NumberReturned']) || items.length,
|
||||
totalMatches: parseInt(values['TotalMatches']) || items.length,
|
||||
updateId: parseInt(values['UpdateID']) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search content directory
|
||||
*/
|
||||
public async search(
|
||||
controlUrl: string,
|
||||
containerId: string,
|
||||
searchCriteria: string,
|
||||
filter: string = '*',
|
||||
startIndex: number = 0,
|
||||
requestCount: number = 100,
|
||||
sortCriteria: string = ''
|
||||
): Promise<IDlnaBrowseResult> {
|
||||
const response = await this.soapAction(controlUrl, UPNP_SERVICE_TYPES.ContentDirectory, 'Search', {
|
||||
ContainerID: containerId,
|
||||
SearchCriteria: searchCriteria,
|
||||
Filter: filter,
|
||||
StartingIndex: startIndex,
|
||||
RequestedCount: requestCount,
|
||||
SortCriteria: sortCriteria,
|
||||
});
|
||||
|
||||
const values = this.extractValues(response, ['Result', 'NumberReturned', 'TotalMatches', 'UpdateID']);
|
||||
|
||||
const resultXml = this.unescapeXml(values['Result'] || '');
|
||||
const items = this.parseDidlResult(resultXml);
|
||||
|
||||
return {
|
||||
items,
|
||||
numberReturned: parseInt(values['NumberReturned']) || items.length,
|
||||
totalMatches: parseInt(values['TotalMatches']) || items.length,
|
||||
updateId: parseInt(values['UpdateID']) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse DIDL-Lite result XML
|
||||
*/
|
||||
private parseDidlResult(xml: string): IDlnaContentItem[] {
|
||||
const items: IDlnaContentItem[] = [];
|
||||
|
||||
// Match container and item elements
|
||||
const elementRegex = /<(container|item)[^>]*>([\s\S]*?)<\/\1>/gi;
|
||||
let match;
|
||||
|
||||
while ((match = elementRegex.exec(xml)) !== null) {
|
||||
const elementXml = match[0];
|
||||
const elementType = match[1];
|
||||
|
||||
// Extract attributes
|
||||
const idMatch = elementXml.match(/id="([^"]*)"/);
|
||||
const parentIdMatch = elementXml.match(/parentID="([^"]*)"/);
|
||||
const restrictedMatch = elementXml.match(/restricted="([^"]*)"/);
|
||||
const childCountMatch = elementXml.match(/childCount="([^"]*)"/);
|
||||
|
||||
const item: IDlnaContentItem = {
|
||||
id: idMatch?.[1] || '',
|
||||
parentId: parentIdMatch?.[1] || '',
|
||||
title: this.extractTagContent(elementXml, 'dc:title'),
|
||||
class: this.extractTagContent(elementXml, 'upnp:class'),
|
||||
restricted: restrictedMatch?.[1] !== '0',
|
||||
childCount: childCountMatch ? parseInt(childCountMatch[1]) : undefined,
|
||||
};
|
||||
|
||||
// Extract resources
|
||||
const resMatches = elementXml.match(/<res[^>]*>([^<]*)<\/res>/gi);
|
||||
if (resMatches) {
|
||||
item.res = resMatches.map((resXml) => {
|
||||
const protocolInfo = resXml.match(/protocolInfo="([^"]*)"/)?.[1] || '';
|
||||
const size = resXml.match(/size="([^"]*)"/)?.[1];
|
||||
const duration = resXml.match(/duration="([^"]*)"/)?.[1];
|
||||
const resolution = resXml.match(/resolution="([^"]*)"/)?.[1];
|
||||
const bitrate = resXml.match(/bitrate="([^"]*)"/)?.[1];
|
||||
const urlMatch = resXml.match(/>([^<]+)</);
|
||||
|
||||
return {
|
||||
url: urlMatch?.[1] || '',
|
||||
protocolInfo,
|
||||
size: size ? parseInt(size) : undefined,
|
||||
duration,
|
||||
resolution,
|
||||
bitrate: bitrate ? parseInt(bitrate) : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Extract optional metadata
|
||||
const albumArt = this.extractTagContent(elementXml, 'upnp:albumArtURI');
|
||||
if (albumArt) item.albumArtUri = albumArt;
|
||||
|
||||
const artist = this.extractTagContent(elementXml, 'dc:creator') ||
|
||||
this.extractTagContent(elementXml, 'upnp:artist');
|
||||
if (artist) item.artist = artist;
|
||||
|
||||
const album = this.extractTagContent(elementXml, 'upnp:album');
|
||||
if (album) item.album = album;
|
||||
|
||||
const genre = this.extractTagContent(elementXml, 'upnp:genre');
|
||||
if (genre) item.genre = genre;
|
||||
|
||||
const date = this.extractTagContent(elementXml, 'dc:date');
|
||||
if (date) item.date = date;
|
||||
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract content from XML tag (handles namespaced tags)
|
||||
*/
|
||||
private extractTagContent(xml: string, tag: string): string {
|
||||
// Handle both with and without namespace prefix
|
||||
const tagName = tag.includes(':') ? tag.split(':')[1] : tag;
|
||||
const regex = new RegExp(`<(?:[^:]*:)?${tagName}[^>]*>([^<]*)<\/(?:[^:]*:)?${tagName}>`, 'i');
|
||||
const match = xml.match(regex);
|
||||
return match ? match[1].trim() : '';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Methods
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate DIDL-Lite metadata for a media URL
|
||||
*/
|
||||
public generateDidlMetadata(
|
||||
title: string,
|
||||
url: string,
|
||||
mimeType: string = 'video/mp4'
|
||||
): string {
|
||||
const protocolInfo = `http-get:*:${mimeType}:*`;
|
||||
|
||||
return `<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
|
||||
<item id="0" parentID="-1" restricted="1">
|
||||
<dc:title>${this.escapeXml(title)}</dc:title>
|
||||
<upnp:class>object.item.videoItem</upnp:class>
|
||||
<res protocolInfo="${protocolInfo}">${this.escapeXml(url)}</res>
|
||||
</item>
|
||||
</DIDL-Lite>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert duration string to seconds
|
||||
*/
|
||||
public durationToSeconds(duration: string): number {
|
||||
if (!duration || duration === 'NOT_IMPLEMENTED') return 0;
|
||||
|
||||
const parts = duration.split(':');
|
||||
if (parts.length !== 3) return 0;
|
||||
|
||||
const hours = parseInt(parts[0]) || 0;
|
||||
const minutes = parseInt(parts[1]) || 0;
|
||||
const seconds = parseFloat(parts[2]) || 0;
|
||||
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert seconds to duration string
|
||||
*/
|
||||
public secondsToDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user