@ecobridge.xyz/devicemanager 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitea/workflows/default_nottags.yaml +66 -0
- package/.gitea/workflows/default_tags.yaml +124 -0
- package/.vscode/launch.json +11 -0
- package/.vscode/settings.json +26 -0
- package/changelog.md +91 -0
- package/dist_ts/00_commitinfo_data.d.ts +8 -0
- package/dist_ts/00_commitinfo_data.js +9 -0
- package/dist_ts/device/device.classes.device.d.ts +205 -0
- package/dist_ts/device/device.classes.device.js +344 -0
- package/dist_ts/devicemanager.classes.devicemanager.d.ts +210 -0
- package/dist_ts/devicemanager.classes.devicemanager.js +835 -0
- package/dist_ts/discovery/discovery.classes.homeassistant.d.ts +101 -0
- package/dist_ts/discovery/discovery.classes.homeassistant.js +313 -0
- package/dist_ts/discovery/discovery.classes.mdns.d.ts +90 -0
- package/dist_ts/discovery/discovery.classes.mdns.js +291 -0
- package/dist_ts/discovery/discovery.classes.networkscanner.d.ts +64 -0
- package/dist_ts/discovery/discovery.classes.networkscanner.js +535 -0
- package/dist_ts/discovery/discovery.classes.ssdp.d.ts +112 -0
- package/dist_ts/discovery/discovery.classes.ssdp.js +255 -0
- package/dist_ts/factories/index.d.ts +190 -0
- package/dist_ts/factories/index.js +350 -0
- package/dist_ts/features/feature.abstract.d.ts +118 -0
- package/dist_ts/features/feature.abstract.js +172 -0
- package/dist_ts/features/feature.camera.d.ts +95 -0
- package/dist_ts/features/feature.camera.js +162 -0
- package/dist_ts/features/feature.climate.d.ts +174 -0
- package/dist_ts/features/feature.climate.js +328 -0
- package/dist_ts/features/feature.cover.d.ts +121 -0
- package/dist_ts/features/feature.cover.js +217 -0
- package/dist_ts/features/feature.fan.d.ts +127 -0
- package/dist_ts/features/feature.fan.js +235 -0
- package/dist_ts/features/feature.light.d.ts +156 -0
- package/dist_ts/features/feature.light.js +296 -0
- package/dist_ts/features/feature.lock.d.ts +103 -0
- package/dist_ts/features/feature.lock.js +170 -0
- package/dist_ts/features/feature.playback.d.ts +98 -0
- package/dist_ts/features/feature.playback.js +191 -0
- package/dist_ts/features/feature.power.d.ts +79 -0
- package/dist_ts/features/feature.power.js +173 -0
- package/dist_ts/features/feature.print.d.ts +78 -0
- package/dist_ts/features/feature.print.js +226 -0
- package/dist_ts/features/feature.scan.d.ts +79 -0
- package/dist_ts/features/feature.scan.js +315 -0
- package/dist_ts/features/feature.sensor.d.ts +95 -0
- package/dist_ts/features/feature.sensor.js +147 -0
- package/dist_ts/features/feature.snmp.d.ts +88 -0
- package/dist_ts/features/feature.snmp.js +175 -0
- package/dist_ts/features/feature.switch.d.ts +75 -0
- package/dist_ts/features/feature.switch.js +126 -0
- package/dist_ts/features/feature.volume.d.ts +104 -0
- package/dist_ts/features/feature.volume.js +186 -0
- package/dist_ts/features/index.d.ts +19 -0
- package/dist_ts/features/index.js +23 -0
- package/dist_ts/helpers/helpers.iprange.d.ts +35 -0
- package/dist_ts/helpers/helpers.iprange.js +151 -0
- package/dist_ts/helpers/helpers.retry.d.ts +25 -0
- package/dist_ts/helpers/helpers.retry.js +69 -0
- package/dist_ts/index.d.ts +15 -0
- package/dist_ts/index.js +40 -0
- package/dist_ts/interfaces/feature.interfaces.d.ts +255 -0
- package/dist_ts/interfaces/feature.interfaces.js +6 -0
- package/dist_ts/interfaces/homeassistant.interfaces.d.ts +550 -0
- package/dist_ts/interfaces/homeassistant.interfaces.js +111 -0
- package/dist_ts/interfaces/index.d.ts +330 -0
- package/dist_ts/interfaces/index.js +16 -0
- package/dist_ts/interfaces/smarthome.interfaces.d.ts +282 -0
- package/dist_ts/interfaces/smarthome.interfaces.js +7 -0
- package/dist_ts/paths.d.ts +1 -0
- package/dist_ts/paths.js +3 -0
- package/dist_ts/plugins.d.ts +23 -0
- package/dist_ts/plugins.js +30 -0
- package/dist_ts/protocols/index.d.ts +12 -0
- package/dist_ts/protocols/index.js +21 -0
- package/dist_ts/protocols/protocol.escl.d.ts +58 -0
- package/dist_ts/protocols/protocol.escl.js +364 -0
- package/dist_ts/protocols/protocol.homeassistant.d.ts +248 -0
- package/dist_ts/protocols/protocol.homeassistant.js +611 -0
- package/dist_ts/protocols/protocol.ipp.d.ts +45 -0
- package/dist_ts/protocols/protocol.ipp.js +284 -0
- package/dist_ts/protocols/protocol.nut.d.ts +160 -0
- package/dist_ts/protocols/protocol.nut.js +364 -0
- package/dist_ts/protocols/protocol.sane.d.ts +104 -0
- package/dist_ts/protocols/protocol.sane.js +597 -0
- package/dist_ts/protocols/protocol.snmp.d.ts +133 -0
- package/dist_ts/protocols/protocol.snmp.js +333 -0
- package/dist_ts/protocols/protocol.upnp.d.ts +207 -0
- package/dist_ts/protocols/protocol.upnp.js +442 -0
- package/dist_ts/protocols/protocol.upssnmp.d.ts +160 -0
- package/dist_ts/protocols/protocol.upssnmp.js +263 -0
- package/npmextra.json +24 -0
- package/package.json +39 -0
- package/readme.hints.md +115 -0
- package/readme.md +624 -0
- package/test/test.ts +261 -0
- package/ts/00_commitinfo_data.ts +8 -0
- package/ts/device/device.classes.device.ts +454 -0
- package/ts/devicemanager.classes.devicemanager.ts +1219 -0
- package/ts/discovery/discovery.classes.homeassistant.ts +376 -0
- package/ts/discovery/discovery.classes.mdns.ts +347 -0
- package/ts/discovery/discovery.classes.networkscanner.ts +648 -0
- package/ts/discovery/discovery.classes.ssdp.ts +357 -0
- package/ts/factories/index.ts +696 -0
- package/ts/features/feature.abstract.ts +251 -0
- package/ts/features/feature.camera.ts +214 -0
- package/ts/features/feature.climate.ts +407 -0
- package/ts/features/feature.cover.ts +278 -0
- package/ts/features/feature.fan.ts +296 -0
- package/ts/features/feature.light.ts +369 -0
- package/ts/features/feature.lock.ts +223 -0
- package/ts/features/feature.playback.ts +246 -0
- package/ts/features/feature.power.ts +231 -0
- package/ts/features/feature.print.ts +297 -0
- package/ts/features/feature.scan.ts +370 -0
- package/ts/features/feature.sensor.ts +202 -0
- package/ts/features/feature.snmp.ts +235 -0
- package/ts/features/feature.switch.ts +170 -0
- package/ts/features/feature.volume.ts +256 -0
- package/ts/features/index.ts +25 -0
- package/ts/helpers/helpers.iprange.ts +172 -0
- package/ts/helpers/helpers.retry.ts +100 -0
- package/ts/index.ts +165 -0
- package/ts/interfaces/feature.interfaces.ts +359 -0
- package/ts/interfaces/homeassistant.interfaces.ts +666 -0
- package/ts/interfaces/index.ts +421 -0
- package/ts/interfaces/smarthome.interfaces.ts +421 -0
- package/ts/paths.ts +5 -0
- package/ts/plugins.ts +41 -0
- package/ts/protocols/index.ts +59 -0
- package/ts/protocols/protocol.escl.ts +439 -0
- package/ts/protocols/protocol.homeassistant.ts +737 -0
- package/ts/protocols/protocol.ipp.ts +329 -0
- package/ts/protocols/protocol.nut.ts +471 -0
- package/ts/protocols/protocol.sane.ts +694 -0
- package/ts/protocols/protocol.snmp.ts +439 -0
- package/ts/protocols/protocol.upnp.ts +627 -0
- package/ts/protocols/protocol.upssnmp.ts +377 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Print Feature
|
|
3
|
+
* Provides document printing capability using IPP protocol
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
|
7
|
+
import { IppProtocol } from '../protocols/index.js';
|
|
8
|
+
import type {
|
|
9
|
+
TPrintProtocol,
|
|
10
|
+
TPrintSides,
|
|
11
|
+
TPrintQuality,
|
|
12
|
+
TPrintColorMode,
|
|
13
|
+
IPrintCapabilities,
|
|
14
|
+
IPrintOptions,
|
|
15
|
+
IPrintJob,
|
|
16
|
+
IPrintFeatureInfo,
|
|
17
|
+
IFeatureOptions,
|
|
18
|
+
} from '../interfaces/feature.interfaces.js';
|
|
19
|
+
import type { IPrinterCapabilities } from '../interfaces/index.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Options for creating a PrintFeature
|
|
23
|
+
*/
|
|
24
|
+
export interface IPrintFeatureOptions extends IFeatureOptions {
|
|
25
|
+
protocol?: TPrintProtocol;
|
|
26
|
+
uri?: string;
|
|
27
|
+
supportsColor?: boolean;
|
|
28
|
+
supportsDuplex?: boolean;
|
|
29
|
+
supportedMediaSizes?: string[];
|
|
30
|
+
supportedMediaTypes?: string[];
|
|
31
|
+
maxCopies?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Print Feature - provides document printing capability
|
|
36
|
+
*
|
|
37
|
+
* Wraps the IPP protocol to provide a unified printing interface.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```typescript
|
|
41
|
+
* const printFeature = device.getFeature<PrintFeature>('print');
|
|
42
|
+
* if (printFeature) {
|
|
43
|
+
* await printFeature.connect();
|
|
44
|
+
* const job = await printFeature.print(pdfBuffer, { copies: 2 });
|
|
45
|
+
* console.log(`Print job ${job.id} created`);
|
|
46
|
+
* }
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export class PrintFeature extends Feature {
|
|
50
|
+
public readonly type = 'print' as const;
|
|
51
|
+
public readonly protocol: TPrintProtocol;
|
|
52
|
+
|
|
53
|
+
// Protocol client
|
|
54
|
+
private ippClient: IppProtocol | null = null;
|
|
55
|
+
|
|
56
|
+
// Configuration
|
|
57
|
+
public readonly uri: string;
|
|
58
|
+
|
|
59
|
+
// Capabilities
|
|
60
|
+
public supportsColor: boolean = true;
|
|
61
|
+
public supportsDuplex: boolean = false;
|
|
62
|
+
public supportedMediaSizes: string[] = ['iso_a4_210x297mm', 'na_letter_8.5x11in'];
|
|
63
|
+
public supportedMediaTypes: string[] = ['stationery'];
|
|
64
|
+
public maxCopies: number = 99;
|
|
65
|
+
public supportedSides: TPrintSides[] = ['one-sided'];
|
|
66
|
+
public supportedQualities: TPrintQuality[] = ['normal'];
|
|
67
|
+
|
|
68
|
+
constructor(
|
|
69
|
+
device: TDeviceReference,
|
|
70
|
+
port: number,
|
|
71
|
+
options?: IPrintFeatureOptions
|
|
72
|
+
) {
|
|
73
|
+
super(device, port, options);
|
|
74
|
+
this.protocol = options?.protocol ?? 'ipp';
|
|
75
|
+
this.uri = options?.uri ?? `ipp://${device.address}:${port}/ipp/print`;
|
|
76
|
+
|
|
77
|
+
// Set capabilities from options if provided
|
|
78
|
+
if (options?.supportsColor !== undefined) this.supportsColor = options.supportsColor;
|
|
79
|
+
if (options?.supportsDuplex !== undefined) {
|
|
80
|
+
this.supportsDuplex = options.supportsDuplex;
|
|
81
|
+
if (options.supportsDuplex) {
|
|
82
|
+
this.supportedSides = ['one-sided', 'two-sided-long-edge', 'two-sided-short-edge'];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (options?.supportedMediaSizes) this.supportedMediaSizes = options.supportedMediaSizes;
|
|
86
|
+
if (options?.supportedMediaTypes) this.supportedMediaTypes = options.supportedMediaTypes;
|
|
87
|
+
if (options?.maxCopies) this.maxCopies = options.maxCopies;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ============================================================================
|
|
91
|
+
// Connection
|
|
92
|
+
// ============================================================================
|
|
93
|
+
|
|
94
|
+
protected async doConnect(): Promise<void> {
|
|
95
|
+
if (this.protocol === 'ipp') {
|
|
96
|
+
// Parse URI to get address, port, and path
|
|
97
|
+
const url = new URL(this.uri.replace('ipp://', 'http://').replace('ipps://', 'https://'));
|
|
98
|
+
const address = url.hostname;
|
|
99
|
+
const port = parseInt(url.port) || this._port;
|
|
100
|
+
const path = url.pathname || '/ipp/print';
|
|
101
|
+
|
|
102
|
+
this.ippClient = new IppProtocol(address, port, path);
|
|
103
|
+
// Verify connection by getting printer attributes
|
|
104
|
+
const attrs = await this.ippClient.getAttributes();
|
|
105
|
+
this.updateCapabilitiesFromIpp(attrs);
|
|
106
|
+
}
|
|
107
|
+
// JetDirect and LPD don't need connection verification
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
protected async doDisconnect(): Promise<void> {
|
|
111
|
+
this.ippClient = null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ============================================================================
|
|
115
|
+
// Printing Operations
|
|
116
|
+
// ============================================================================
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get printer capabilities
|
|
120
|
+
*/
|
|
121
|
+
public async getCapabilities(): Promise<IPrintCapabilities> {
|
|
122
|
+
return {
|
|
123
|
+
colorSupported: this.supportsColor,
|
|
124
|
+
duplexSupported: this.supportsDuplex,
|
|
125
|
+
mediaSizes: this.supportedMediaSizes,
|
|
126
|
+
mediaTypes: this.supportedMediaTypes,
|
|
127
|
+
resolutions: [300, 600],
|
|
128
|
+
maxCopies: this.maxCopies,
|
|
129
|
+
sidesSupported: this.supportedSides,
|
|
130
|
+
qualitySupported: this.supportedQualities,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Print a document
|
|
136
|
+
*/
|
|
137
|
+
public async print(data: Buffer, options?: IPrintOptions): Promise<IPrintJob> {
|
|
138
|
+
if (!this.isConnected) {
|
|
139
|
+
throw new Error('Print feature not connected');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (this.protocol === 'ipp' && this.ippClient) {
|
|
143
|
+
return this.printWithIpp(data, options);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
throw new Error(`Protocol ${this.protocol} not supported yet`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Get active print jobs
|
|
151
|
+
*/
|
|
152
|
+
public async getJobs(): Promise<IPrintJob[]> {
|
|
153
|
+
if (!this.isConnected || !this.ippClient) {
|
|
154
|
+
throw new Error('Print feature not connected');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return this.ippClient.getJobs();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get info about a specific job
|
|
162
|
+
*/
|
|
163
|
+
public async getJobInfo(jobId: number): Promise<IPrintJob> {
|
|
164
|
+
if (!this.isConnected || !this.ippClient) {
|
|
165
|
+
throw new Error('Print feature not connected');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return this.ippClient.getJobInfo(jobId);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Cancel a print job
|
|
173
|
+
*/
|
|
174
|
+
public async cancelJob(jobId: number): Promise<void> {
|
|
175
|
+
if (!this.isConnected || !this.ippClient) {
|
|
176
|
+
throw new Error('Print feature not connected');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await this.ippClient.cancelJob(jobId);
|
|
180
|
+
this.emit('job:cancelled', jobId);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ============================================================================
|
|
184
|
+
// Protocol-Specific Printing
|
|
185
|
+
// ============================================================================
|
|
186
|
+
|
|
187
|
+
private async printWithIpp(data: Buffer, options?: IPrintOptions): Promise<IPrintJob> {
|
|
188
|
+
if (!this.ippClient) {
|
|
189
|
+
throw new Error('IPP client not initialized');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
this.emit('print:started', options);
|
|
193
|
+
|
|
194
|
+
// IppProtocol.print() accepts IPrintOptions and returns IPrintJob
|
|
195
|
+
const job = await this.ippClient.print(data, options);
|
|
196
|
+
|
|
197
|
+
this.emit('print:submitted', job);
|
|
198
|
+
return job;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ============================================================================
|
|
202
|
+
// Helper Methods
|
|
203
|
+
// ============================================================================
|
|
204
|
+
|
|
205
|
+
private updateCapabilitiesFromIpp(caps: IPrinterCapabilities): void {
|
|
206
|
+
this.supportsColor = caps.colorSupported;
|
|
207
|
+
this.supportsDuplex = caps.duplexSupported;
|
|
208
|
+
this.maxCopies = caps.maxCopies;
|
|
209
|
+
|
|
210
|
+
if (caps.mediaSizes && caps.mediaSizes.length > 0) {
|
|
211
|
+
this.supportedMediaSizes = caps.mediaSizes;
|
|
212
|
+
}
|
|
213
|
+
if (caps.mediaTypes && caps.mediaTypes.length > 0) {
|
|
214
|
+
this.supportedMediaTypes = caps.mediaTypes;
|
|
215
|
+
}
|
|
216
|
+
if (caps.sidesSupported && caps.sidesSupported.length > 0) {
|
|
217
|
+
this.supportedSides = caps.sidesSupported.filter((s): s is TPrintSides =>
|
|
218
|
+
['one-sided', 'two-sided-long-edge', 'two-sided-short-edge'].includes(s)
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
if (caps.qualitySupported && caps.qualitySupported.length > 0) {
|
|
222
|
+
this.supportedQualities = caps.qualitySupported.filter((q): q is TPrintQuality =>
|
|
223
|
+
['draft', 'normal', 'high'].includes(q)
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private qualityToIpp(quality: TPrintQuality): number {
|
|
229
|
+
switch (quality) {
|
|
230
|
+
case 'draft': return 3;
|
|
231
|
+
case 'normal': return 4;
|
|
232
|
+
case 'high': return 5;
|
|
233
|
+
default: return 4;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private mapIppJob(job: Record<string, unknown>): IPrintJob {
|
|
238
|
+
const stateMap: Record<number, IPrintJob['state']> = {
|
|
239
|
+
3: 'pending',
|
|
240
|
+
4: 'pending',
|
|
241
|
+
5: 'processing',
|
|
242
|
+
6: 'processing',
|
|
243
|
+
7: 'canceled',
|
|
244
|
+
8: 'aborted',
|
|
245
|
+
9: 'completed',
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
id: job['job-id'] as number,
|
|
250
|
+
name: job['job-name'] as string ?? 'Unknown',
|
|
251
|
+
state: stateMap[(job['job-state'] as number) ?? 3] ?? 'pending',
|
|
252
|
+
stateReason: (job['job-state-reasons'] as string[])?.[0],
|
|
253
|
+
createdAt: new Date((job['time-at-creation'] as number) * 1000),
|
|
254
|
+
completedAt: job['time-at-completed']
|
|
255
|
+
? new Date((job['time-at-completed'] as number) * 1000)
|
|
256
|
+
: undefined,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ============================================================================
|
|
261
|
+
// Serialization
|
|
262
|
+
// ============================================================================
|
|
263
|
+
|
|
264
|
+
public getFeatureInfo(): IPrintFeatureInfo {
|
|
265
|
+
return {
|
|
266
|
+
...this.getBaseFeatureInfo(),
|
|
267
|
+
type: 'print',
|
|
268
|
+
protocol: this.protocol,
|
|
269
|
+
supportsColor: this.supportsColor,
|
|
270
|
+
supportsDuplex: this.supportsDuplex,
|
|
271
|
+
supportedMediaSizes: this.supportedMediaSizes,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ============================================================================
|
|
276
|
+
// Static Factory
|
|
277
|
+
// ============================================================================
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Create from discovery metadata
|
|
281
|
+
*/
|
|
282
|
+
public static fromDiscovery(
|
|
283
|
+
device: TDeviceReference,
|
|
284
|
+
port: number,
|
|
285
|
+
protocol: TPrintProtocol,
|
|
286
|
+
metadata: Record<string, unknown>
|
|
287
|
+
): PrintFeature {
|
|
288
|
+
const txtRecords = metadata.txtRecords as Record<string, string> ?? {};
|
|
289
|
+
|
|
290
|
+
return new PrintFeature(device, port, {
|
|
291
|
+
protocol,
|
|
292
|
+
uri: metadata.uri as string,
|
|
293
|
+
supportsColor: txtRecords['Color'] === 'T' || txtRecords['color'] === 'true',
|
|
294
|
+
supportsDuplex: txtRecords['Duplex'] === 'T' || txtRecords['duplex'] === 'true',
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scan Feature
|
|
3
|
+
* Provides document scanning capability using eSCL or SANE protocols
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
|
7
|
+
import { EsclProtocol, SaneProtocol } from '../protocols/index.js';
|
|
8
|
+
import type {
|
|
9
|
+
TScanProtocol,
|
|
10
|
+
TScanFormat,
|
|
11
|
+
TColorMode,
|
|
12
|
+
TScanSource,
|
|
13
|
+
IScanCapabilities,
|
|
14
|
+
IScanOptions,
|
|
15
|
+
IScanResult,
|
|
16
|
+
IScanFeatureInfo,
|
|
17
|
+
IFeatureOptions,
|
|
18
|
+
} from '../interfaces/feature.interfaces.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Options for creating a ScanFeature
|
|
22
|
+
*/
|
|
23
|
+
export interface IScanFeatureOptions extends IFeatureOptions {
|
|
24
|
+
protocol: TScanProtocol;
|
|
25
|
+
secure?: boolean;
|
|
26
|
+
deviceName?: string; // For SANE
|
|
27
|
+
supportedFormats?: TScanFormat[];
|
|
28
|
+
supportedResolutions?: number[];
|
|
29
|
+
supportedColorModes?: TColorMode[];
|
|
30
|
+
supportedSources?: TScanSource[];
|
|
31
|
+
hasAdf?: boolean;
|
|
32
|
+
hasDuplex?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Scan Feature - provides document scanning capability
|
|
37
|
+
*
|
|
38
|
+
* Wraps eSCL (AirScan) or SANE protocols to provide a unified scanning interface.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```typescript
|
|
42
|
+
* const scanFeature = device.getFeature<ScanFeature>('scan');
|
|
43
|
+
* if (scanFeature) {
|
|
44
|
+
* await scanFeature.connect();
|
|
45
|
+
* const result = await scanFeature.scan({ format: 'pdf', resolution: 300 });
|
|
46
|
+
* console.log(`Scanned ${result.width}x${result.height} pixels`);
|
|
47
|
+
* }
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export class ScanFeature extends Feature {
|
|
51
|
+
public readonly type = 'scan' as const;
|
|
52
|
+
public readonly protocol: TScanProtocol;
|
|
53
|
+
|
|
54
|
+
// Protocol clients
|
|
55
|
+
private esclClient: EsclProtocol | null = null;
|
|
56
|
+
private saneClient: SaneProtocol | null = null;
|
|
57
|
+
|
|
58
|
+
// Configuration
|
|
59
|
+
private readonly isSecure: boolean;
|
|
60
|
+
private readonly deviceName: string;
|
|
61
|
+
|
|
62
|
+
// Capabilities
|
|
63
|
+
public supportedFormats: TScanFormat[] = ['jpeg', 'png', 'pdf'];
|
|
64
|
+
public supportedResolutions: number[] = [75, 150, 300, 600];
|
|
65
|
+
public supportedColorModes: TColorMode[] = ['color', 'grayscale', 'blackwhite'];
|
|
66
|
+
public supportedSources: TScanSource[] = ['flatbed'];
|
|
67
|
+
public hasAdf: boolean = false;
|
|
68
|
+
public hasDuplex: boolean = false;
|
|
69
|
+
public maxWidth: number = 215.9; // A4 width in mm
|
|
70
|
+
public maxHeight: number = 297; // A4 height in mm
|
|
71
|
+
public minWidth: number = 10;
|
|
72
|
+
public minHeight: number = 10;
|
|
73
|
+
|
|
74
|
+
constructor(
|
|
75
|
+
device: TDeviceReference,
|
|
76
|
+
port: number,
|
|
77
|
+
options: IScanFeatureOptions
|
|
78
|
+
) {
|
|
79
|
+
super(device, port, options);
|
|
80
|
+
this.protocol = options.protocol;
|
|
81
|
+
this.isSecure = options.secure ?? false;
|
|
82
|
+
this.deviceName = options.deviceName ?? '';
|
|
83
|
+
|
|
84
|
+
// Set capabilities from options if provided
|
|
85
|
+
if (options.supportedFormats) this.supportedFormats = options.supportedFormats;
|
|
86
|
+
if (options.supportedResolutions) this.supportedResolutions = options.supportedResolutions;
|
|
87
|
+
if (options.supportedColorModes) this.supportedColorModes = options.supportedColorModes;
|
|
88
|
+
if (options.supportedSources) this.supportedSources = options.supportedSources;
|
|
89
|
+
if (options.hasAdf !== undefined) this.hasAdf = options.hasAdf;
|
|
90
|
+
if (options.hasDuplex !== undefined) this.hasDuplex = options.hasDuplex;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ============================================================================
|
|
94
|
+
// Connection
|
|
95
|
+
// ============================================================================
|
|
96
|
+
|
|
97
|
+
protected async doConnect(): Promise<void> {
|
|
98
|
+
if (this.protocol === 'escl') {
|
|
99
|
+
this.esclClient = new EsclProtocol(this.address, this._port, this.isSecure);
|
|
100
|
+
// Fetch capabilities to verify connection
|
|
101
|
+
const caps = await this.esclClient.getCapabilities();
|
|
102
|
+
this.updateCapabilitiesFromEscl(caps);
|
|
103
|
+
} else if (this.protocol === 'sane') {
|
|
104
|
+
this.saneClient = new SaneProtocol(this.address, this._port);
|
|
105
|
+
await this.saneClient.connect();
|
|
106
|
+
// Open the device if we have a name
|
|
107
|
+
if (this.deviceName) {
|
|
108
|
+
await this.saneClient.open(this.deviceName);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
protected async doDisconnect(): Promise<void> {
|
|
114
|
+
if (this.saneClient) {
|
|
115
|
+
try {
|
|
116
|
+
await this.saneClient.close();
|
|
117
|
+
await this.saneClient.disconnect();
|
|
118
|
+
} catch {
|
|
119
|
+
// Ignore disconnect errors
|
|
120
|
+
}
|
|
121
|
+
this.saneClient = null;
|
|
122
|
+
}
|
|
123
|
+
this.esclClient = null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ============================================================================
|
|
127
|
+
// Scanning Operations
|
|
128
|
+
// ============================================================================
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get scanner capabilities
|
|
132
|
+
*/
|
|
133
|
+
public async getCapabilities(): Promise<IScanCapabilities> {
|
|
134
|
+
return {
|
|
135
|
+
resolutions: this.supportedResolutions,
|
|
136
|
+
formats: this.supportedFormats,
|
|
137
|
+
colorModes: this.supportedColorModes,
|
|
138
|
+
sources: this.supportedSources,
|
|
139
|
+
maxWidth: this.maxWidth,
|
|
140
|
+
maxHeight: this.maxHeight,
|
|
141
|
+
minWidth: this.minWidth,
|
|
142
|
+
minHeight: this.minHeight,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Perform a scan
|
|
148
|
+
*/
|
|
149
|
+
public async scan(options?: IScanOptions): Promise<IScanResult> {
|
|
150
|
+
if (!this.isConnected) {
|
|
151
|
+
throw new Error('Scan feature not connected');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const opts = this.resolveOptions(options);
|
|
155
|
+
|
|
156
|
+
if (this.protocol === 'escl' && this.esclClient) {
|
|
157
|
+
return this.scanWithEscl(opts);
|
|
158
|
+
} else if (this.protocol === 'sane' && this.saneClient) {
|
|
159
|
+
return this.scanWithSane(opts);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
throw new Error('No scanner protocol available');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Cancel an ongoing scan
|
|
167
|
+
*/
|
|
168
|
+
public async cancelScan(): Promise<void> {
|
|
169
|
+
if (this.protocol === 'sane' && this.saneClient) {
|
|
170
|
+
await this.saneClient.cancel();
|
|
171
|
+
}
|
|
172
|
+
// eSCL cancellation is handled by deleting the job
|
|
173
|
+
this.emit('scan:cancelled');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ============================================================================
|
|
177
|
+
// Protocol-Specific Scanning
|
|
178
|
+
// ============================================================================
|
|
179
|
+
|
|
180
|
+
private async scanWithEscl(options: Required<IScanOptions>): Promise<IScanResult> {
|
|
181
|
+
if (!this.esclClient) {
|
|
182
|
+
throw new Error('eSCL client not initialized');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
this.emit('scan:started', options);
|
|
186
|
+
|
|
187
|
+
// Use the protocol's scan method which handles job submission,
|
|
188
|
+
// waiting for completion, and downloading in one operation
|
|
189
|
+
const result = await this.esclClient.scan({
|
|
190
|
+
format: options.format,
|
|
191
|
+
resolution: options.resolution,
|
|
192
|
+
colorMode: options.colorMode,
|
|
193
|
+
source: options.source,
|
|
194
|
+
area: options.area,
|
|
195
|
+
intent: options.intent,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
this.emit('scan:completed', result);
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private async scanWithSane(options: Required<IScanOptions>): Promise<IScanResult> {
|
|
203
|
+
if (!this.saneClient) {
|
|
204
|
+
throw new Error('SANE client not initialized');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
this.emit('scan:started', options);
|
|
208
|
+
|
|
209
|
+
// Use the protocol's scan method which handles option configuration,
|
|
210
|
+
// parameter retrieval, and image reading in one operation
|
|
211
|
+
const result = await this.saneClient.scan({
|
|
212
|
+
format: options.format,
|
|
213
|
+
resolution: options.resolution,
|
|
214
|
+
colorMode: options.colorMode,
|
|
215
|
+
source: options.source,
|
|
216
|
+
area: options.area,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
this.emit('scan:completed', result);
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ============================================================================
|
|
224
|
+
// Helper Methods
|
|
225
|
+
// ============================================================================
|
|
226
|
+
|
|
227
|
+
private resolveOptions(options?: IScanOptions): Required<IScanOptions> {
|
|
228
|
+
return {
|
|
229
|
+
resolution: options?.resolution ?? 300,
|
|
230
|
+
format: options?.format ?? 'jpeg',
|
|
231
|
+
colorMode: options?.colorMode ?? 'color',
|
|
232
|
+
source: options?.source ?? 'flatbed',
|
|
233
|
+
area: options?.area ?? {
|
|
234
|
+
x: 0,
|
|
235
|
+
y: 0,
|
|
236
|
+
width: this.maxWidth,
|
|
237
|
+
height: this.maxHeight,
|
|
238
|
+
},
|
|
239
|
+
intent: options?.intent ?? 'document',
|
|
240
|
+
quality: options?.quality ?? 85,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private updateCapabilitiesFromEscl(caps: any): void {
|
|
245
|
+
if (caps.platen) {
|
|
246
|
+
this.supportedResolutions = caps.platen.supportedResolutions || this.supportedResolutions;
|
|
247
|
+
this.maxWidth = caps.platen.maxWidth || this.maxWidth;
|
|
248
|
+
this.maxHeight = caps.platen.maxHeight || this.maxHeight;
|
|
249
|
+
this.minWidth = caps.platen.minWidth || this.minWidth;
|
|
250
|
+
this.minHeight = caps.platen.minHeight || this.minHeight;
|
|
251
|
+
}
|
|
252
|
+
if (caps.adf) {
|
|
253
|
+
this.hasAdf = true;
|
|
254
|
+
if (!this.supportedSources.includes('adf')) {
|
|
255
|
+
this.supportedSources.push('adf');
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (caps.adfDuplex) {
|
|
259
|
+
this.hasDuplex = true;
|
|
260
|
+
if (!this.supportedSources.includes('adf-duplex')) {
|
|
261
|
+
this.supportedSources.push('adf-duplex');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private colorModeToSane(mode: TColorMode): string {
|
|
267
|
+
switch (mode) {
|
|
268
|
+
case 'color': return 'Color';
|
|
269
|
+
case 'grayscale': return 'Gray';
|
|
270
|
+
case 'blackwhite': return 'Lineart';
|
|
271
|
+
default: return 'Color';
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private getMimeType(format: TScanFormat): string {
|
|
276
|
+
switch (format) {
|
|
277
|
+
case 'jpeg': return 'image/jpeg';
|
|
278
|
+
case 'png': return 'image/png';
|
|
279
|
+
case 'pdf': return 'application/pdf';
|
|
280
|
+
case 'tiff': return 'image/tiff';
|
|
281
|
+
default: return 'application/octet-stream';
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ============================================================================
|
|
286
|
+
// Serialization
|
|
287
|
+
// ============================================================================
|
|
288
|
+
|
|
289
|
+
public getFeatureInfo(): IScanFeatureInfo {
|
|
290
|
+
return {
|
|
291
|
+
...this.getBaseFeatureInfo(),
|
|
292
|
+
type: 'scan',
|
|
293
|
+
protocol: this.protocol,
|
|
294
|
+
supportedFormats: this.supportedFormats,
|
|
295
|
+
supportedResolutions: this.supportedResolutions,
|
|
296
|
+
supportedColorModes: this.supportedColorModes,
|
|
297
|
+
supportedSources: this.supportedSources,
|
|
298
|
+
hasAdf: this.hasAdf,
|
|
299
|
+
hasDuplex: this.hasDuplex,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ============================================================================
|
|
304
|
+
// Static Factory
|
|
305
|
+
// ============================================================================
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Create from discovery metadata
|
|
309
|
+
*/
|
|
310
|
+
public static fromDiscovery(
|
|
311
|
+
device: TDeviceReference,
|
|
312
|
+
port: number,
|
|
313
|
+
protocol: TScanProtocol,
|
|
314
|
+
metadata: Record<string, unknown>
|
|
315
|
+
): ScanFeature {
|
|
316
|
+
const txtRecords = metadata.txtRecords as Record<string, string> ?? {};
|
|
317
|
+
|
|
318
|
+
return new ScanFeature(device, port, {
|
|
319
|
+
protocol,
|
|
320
|
+
secure: metadata.secure as boolean ?? port === 443,
|
|
321
|
+
deviceName: metadata.deviceName as string,
|
|
322
|
+
supportedFormats: parseFormats(txtRecords),
|
|
323
|
+
supportedResolutions: parseResolutions(txtRecords),
|
|
324
|
+
supportedColorModes: parseColorModes(txtRecords),
|
|
325
|
+
supportedSources: parseSources(txtRecords),
|
|
326
|
+
hasAdf: Boolean(metadata.hasAdf),
|
|
327
|
+
hasDuplex: Boolean(metadata.hasDuplex),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ============================================================================
|
|
333
|
+
// Parsing Helpers
|
|
334
|
+
// ============================================================================
|
|
335
|
+
|
|
336
|
+
function parseFormats(txt: Record<string, string>): TScanFormat[] {
|
|
337
|
+
const pdl = txt['pdl'] || '';
|
|
338
|
+
const formats: TScanFormat[] = [];
|
|
339
|
+
if (pdl.includes('image/jpeg')) formats.push('jpeg');
|
|
340
|
+
if (pdl.includes('image/png')) formats.push('png');
|
|
341
|
+
if (pdl.includes('application/pdf')) formats.push('pdf');
|
|
342
|
+
if (pdl.includes('image/tiff')) formats.push('tiff');
|
|
343
|
+
return formats.length > 0 ? formats : ['jpeg', 'png', 'pdf'];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function parseResolutions(txt: Record<string, string>): number[] {
|
|
347
|
+
const rs = txt['rs'] || '';
|
|
348
|
+
if (!rs) return [75, 150, 300, 600];
|
|
349
|
+
return rs.split(',')
|
|
350
|
+
.map((r) => parseInt(r.trim(), 10))
|
|
351
|
+
.filter((r) => !isNaN(r) && r > 0);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function parseColorModes(txt: Record<string, string>): TColorMode[] {
|
|
355
|
+
const cs = txt['cs'] || '';
|
|
356
|
+
const modes: TColorMode[] = [];
|
|
357
|
+
if (cs.includes('color') || cs.includes('RGB')) modes.push('color');
|
|
358
|
+
if (cs.includes('grayscale') || cs.includes('gray')) modes.push('grayscale');
|
|
359
|
+
if (cs.includes('binary') || cs.includes('lineart')) modes.push('blackwhite');
|
|
360
|
+
return modes.length > 0 ? modes : ['color', 'grayscale', 'blackwhite'];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function parseSources(txt: Record<string, string>): TScanSource[] {
|
|
364
|
+
const is = txt['is'] || '';
|
|
365
|
+
const sources: TScanSource[] = [];
|
|
366
|
+
if (is.includes('platen') || is.includes('flatbed') || !is) sources.push('flatbed');
|
|
367
|
+
if (is.includes('adf') && is.includes('duplex')) sources.push('adf-duplex');
|
|
368
|
+
else if (is.includes('adf')) sources.push('adf');
|
|
369
|
+
return sources.length > 0 ? sources : ['flatbed'];
|
|
370
|
+
}
|