@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.
Files changed (137) hide show
  1. package/.gitea/workflows/default_nottags.yaml +66 -0
  2. package/.gitea/workflows/default_tags.yaml +124 -0
  3. package/.vscode/launch.json +11 -0
  4. package/.vscode/settings.json +26 -0
  5. package/changelog.md +91 -0
  6. package/dist_ts/00_commitinfo_data.d.ts +8 -0
  7. package/dist_ts/00_commitinfo_data.js +9 -0
  8. package/dist_ts/device/device.classes.device.d.ts +205 -0
  9. package/dist_ts/device/device.classes.device.js +344 -0
  10. package/dist_ts/devicemanager.classes.devicemanager.d.ts +210 -0
  11. package/dist_ts/devicemanager.classes.devicemanager.js +835 -0
  12. package/dist_ts/discovery/discovery.classes.homeassistant.d.ts +101 -0
  13. package/dist_ts/discovery/discovery.classes.homeassistant.js +313 -0
  14. package/dist_ts/discovery/discovery.classes.mdns.d.ts +90 -0
  15. package/dist_ts/discovery/discovery.classes.mdns.js +291 -0
  16. package/dist_ts/discovery/discovery.classes.networkscanner.d.ts +64 -0
  17. package/dist_ts/discovery/discovery.classes.networkscanner.js +535 -0
  18. package/dist_ts/discovery/discovery.classes.ssdp.d.ts +112 -0
  19. package/dist_ts/discovery/discovery.classes.ssdp.js +255 -0
  20. package/dist_ts/factories/index.d.ts +190 -0
  21. package/dist_ts/factories/index.js +350 -0
  22. package/dist_ts/features/feature.abstract.d.ts +118 -0
  23. package/dist_ts/features/feature.abstract.js +172 -0
  24. package/dist_ts/features/feature.camera.d.ts +95 -0
  25. package/dist_ts/features/feature.camera.js +162 -0
  26. package/dist_ts/features/feature.climate.d.ts +174 -0
  27. package/dist_ts/features/feature.climate.js +328 -0
  28. package/dist_ts/features/feature.cover.d.ts +121 -0
  29. package/dist_ts/features/feature.cover.js +217 -0
  30. package/dist_ts/features/feature.fan.d.ts +127 -0
  31. package/dist_ts/features/feature.fan.js +235 -0
  32. package/dist_ts/features/feature.light.d.ts +156 -0
  33. package/dist_ts/features/feature.light.js +296 -0
  34. package/dist_ts/features/feature.lock.d.ts +103 -0
  35. package/dist_ts/features/feature.lock.js +170 -0
  36. package/dist_ts/features/feature.playback.d.ts +98 -0
  37. package/dist_ts/features/feature.playback.js +191 -0
  38. package/dist_ts/features/feature.power.d.ts +79 -0
  39. package/dist_ts/features/feature.power.js +173 -0
  40. package/dist_ts/features/feature.print.d.ts +78 -0
  41. package/dist_ts/features/feature.print.js +226 -0
  42. package/dist_ts/features/feature.scan.d.ts +79 -0
  43. package/dist_ts/features/feature.scan.js +315 -0
  44. package/dist_ts/features/feature.sensor.d.ts +95 -0
  45. package/dist_ts/features/feature.sensor.js +147 -0
  46. package/dist_ts/features/feature.snmp.d.ts +88 -0
  47. package/dist_ts/features/feature.snmp.js +175 -0
  48. package/dist_ts/features/feature.switch.d.ts +75 -0
  49. package/dist_ts/features/feature.switch.js +126 -0
  50. package/dist_ts/features/feature.volume.d.ts +104 -0
  51. package/dist_ts/features/feature.volume.js +186 -0
  52. package/dist_ts/features/index.d.ts +19 -0
  53. package/dist_ts/features/index.js +23 -0
  54. package/dist_ts/helpers/helpers.iprange.d.ts +35 -0
  55. package/dist_ts/helpers/helpers.iprange.js +151 -0
  56. package/dist_ts/helpers/helpers.retry.d.ts +25 -0
  57. package/dist_ts/helpers/helpers.retry.js +69 -0
  58. package/dist_ts/index.d.ts +15 -0
  59. package/dist_ts/index.js +40 -0
  60. package/dist_ts/interfaces/feature.interfaces.d.ts +255 -0
  61. package/dist_ts/interfaces/feature.interfaces.js +6 -0
  62. package/dist_ts/interfaces/homeassistant.interfaces.d.ts +550 -0
  63. package/dist_ts/interfaces/homeassistant.interfaces.js +111 -0
  64. package/dist_ts/interfaces/index.d.ts +330 -0
  65. package/dist_ts/interfaces/index.js +16 -0
  66. package/dist_ts/interfaces/smarthome.interfaces.d.ts +282 -0
  67. package/dist_ts/interfaces/smarthome.interfaces.js +7 -0
  68. package/dist_ts/paths.d.ts +1 -0
  69. package/dist_ts/paths.js +3 -0
  70. package/dist_ts/plugins.d.ts +23 -0
  71. package/dist_ts/plugins.js +30 -0
  72. package/dist_ts/protocols/index.d.ts +12 -0
  73. package/dist_ts/protocols/index.js +21 -0
  74. package/dist_ts/protocols/protocol.escl.d.ts +58 -0
  75. package/dist_ts/protocols/protocol.escl.js +364 -0
  76. package/dist_ts/protocols/protocol.homeassistant.d.ts +248 -0
  77. package/dist_ts/protocols/protocol.homeassistant.js +611 -0
  78. package/dist_ts/protocols/protocol.ipp.d.ts +45 -0
  79. package/dist_ts/protocols/protocol.ipp.js +284 -0
  80. package/dist_ts/protocols/protocol.nut.d.ts +160 -0
  81. package/dist_ts/protocols/protocol.nut.js +364 -0
  82. package/dist_ts/protocols/protocol.sane.d.ts +104 -0
  83. package/dist_ts/protocols/protocol.sane.js +597 -0
  84. package/dist_ts/protocols/protocol.snmp.d.ts +133 -0
  85. package/dist_ts/protocols/protocol.snmp.js +333 -0
  86. package/dist_ts/protocols/protocol.upnp.d.ts +207 -0
  87. package/dist_ts/protocols/protocol.upnp.js +442 -0
  88. package/dist_ts/protocols/protocol.upssnmp.d.ts +160 -0
  89. package/dist_ts/protocols/protocol.upssnmp.js +263 -0
  90. package/npmextra.json +24 -0
  91. package/package.json +39 -0
  92. package/readme.hints.md +115 -0
  93. package/readme.md +624 -0
  94. package/test/test.ts +261 -0
  95. package/ts/00_commitinfo_data.ts +8 -0
  96. package/ts/device/device.classes.device.ts +454 -0
  97. package/ts/devicemanager.classes.devicemanager.ts +1219 -0
  98. package/ts/discovery/discovery.classes.homeassistant.ts +376 -0
  99. package/ts/discovery/discovery.classes.mdns.ts +347 -0
  100. package/ts/discovery/discovery.classes.networkscanner.ts +648 -0
  101. package/ts/discovery/discovery.classes.ssdp.ts +357 -0
  102. package/ts/factories/index.ts +696 -0
  103. package/ts/features/feature.abstract.ts +251 -0
  104. package/ts/features/feature.camera.ts +214 -0
  105. package/ts/features/feature.climate.ts +407 -0
  106. package/ts/features/feature.cover.ts +278 -0
  107. package/ts/features/feature.fan.ts +296 -0
  108. package/ts/features/feature.light.ts +369 -0
  109. package/ts/features/feature.lock.ts +223 -0
  110. package/ts/features/feature.playback.ts +246 -0
  111. package/ts/features/feature.power.ts +231 -0
  112. package/ts/features/feature.print.ts +297 -0
  113. package/ts/features/feature.scan.ts +370 -0
  114. package/ts/features/feature.sensor.ts +202 -0
  115. package/ts/features/feature.snmp.ts +235 -0
  116. package/ts/features/feature.switch.ts +170 -0
  117. package/ts/features/feature.volume.ts +256 -0
  118. package/ts/features/index.ts +25 -0
  119. package/ts/helpers/helpers.iprange.ts +172 -0
  120. package/ts/helpers/helpers.retry.ts +100 -0
  121. package/ts/index.ts +165 -0
  122. package/ts/interfaces/feature.interfaces.ts +359 -0
  123. package/ts/interfaces/homeassistant.interfaces.ts +666 -0
  124. package/ts/interfaces/index.ts +421 -0
  125. package/ts/interfaces/smarthome.interfaces.ts +421 -0
  126. package/ts/paths.ts +5 -0
  127. package/ts/plugins.ts +41 -0
  128. package/ts/protocols/index.ts +59 -0
  129. package/ts/protocols/protocol.escl.ts +439 -0
  130. package/ts/protocols/protocol.homeassistant.ts +737 -0
  131. package/ts/protocols/protocol.ipp.ts +329 -0
  132. package/ts/protocols/protocol.nut.ts +471 -0
  133. package/ts/protocols/protocol.sane.ts +694 -0
  134. package/ts/protocols/protocol.snmp.ts +439 -0
  135. package/ts/protocols/protocol.upnp.ts +627 -0
  136. package/ts/protocols/protocol.upssnmp.ts +377 -0
  137. 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
+ }