@ecobridge.xyz/devicemanager 3.1.0 → 3.2.0

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 (78) hide show
  1. package/.gitea/workflows/default_tags.yaml +0 -21
  2. package/.smartconfig.json +69 -0
  3. package/changelog.md +38 -1
  4. package/dist_ts/00_commitinfo_data.js +1 -1
  5. package/dist_ts/device/device.classes.device.d.ts +2 -2
  6. package/dist_ts/device/device.classes.device.js +3 -3
  7. package/dist_ts/devicemanager.classes.devicemanager.d.ts +17 -4
  8. package/dist_ts/devicemanager.classes.devicemanager.js +99 -11
  9. package/dist_ts/discovery/discovery.classes.mdns.js +46 -11
  10. package/dist_ts/discovery/discovery.classes.networkscanner.d.ts +5 -1
  11. package/dist_ts/discovery/discovery.classes.networkscanner.js +44 -3
  12. package/dist_ts/discovery/discovery.classes.ssdp.d.ts +52 -21
  13. package/dist_ts/discovery/discovery.classes.ssdp.js +407 -117
  14. package/dist_ts/events/events.observable.d.ts +15 -0
  15. package/dist_ts/events/events.observable.js +22 -0
  16. package/dist_ts/events/index.d.ts +1 -0
  17. package/dist_ts/events/index.js +2 -0
  18. package/dist_ts/factories/index.js +7 -5
  19. package/dist_ts/features/feature.abstract.d.ts +2 -2
  20. package/dist_ts/features/feature.abstract.js +3 -3
  21. package/dist_ts/features/feature.print.d.ts +3 -0
  22. package/dist_ts/features/feature.print.js +17 -2
  23. package/dist_ts/features/feature.scan.d.ts +5 -2
  24. package/dist_ts/features/feature.scan.js +98 -27
  25. package/dist_ts/index.d.ts +4 -2
  26. package/dist_ts/index.js +4 -2
  27. package/dist_ts/interfaces/feature.interfaces.d.ts +22 -2
  28. package/dist_ts/interfaces/index.d.ts +22 -4
  29. package/dist_ts/interfaces/index.js +1 -1
  30. package/dist_ts/plugins.d.ts +6 -15
  31. package/dist_ts/plugins.js +7 -19
  32. package/dist_ts/protocols/index.d.ts +2 -1
  33. package/dist_ts/protocols/index.js +4 -2
  34. package/dist_ts/protocols/protocol.brother.d.ts +67 -0
  35. package/dist_ts/protocols/protocol.brother.js +560 -0
  36. package/dist_ts/protocols/protocol.escl.d.ts +13 -4
  37. package/dist_ts/protocols/protocol.escl.js +345 -178
  38. package/dist_ts/protocols/protocol.ipp.d.ts +2 -0
  39. package/dist_ts/protocols/protocol.ipp.js +22 -2
  40. package/dist_ts/providers/index.d.ts +1 -0
  41. package/dist_ts/providers/index.js +2 -0
  42. package/dist_ts/providers/provider.smb.d.ts +60 -0
  43. package/dist_ts/providers/provider.smb.js +224 -0
  44. package/license.md +21 -0
  45. package/package.json +15 -20
  46. package/pnpm-workspace.yaml +6 -0
  47. package/readme.md +157 -78
  48. package/test/test.brother.node.ts +306 -0
  49. package/test/test.escl.node.ts +233 -0
  50. package/test/test.ipp.node.ts +75 -0
  51. package/test/{test.ts → test.node.ts} +37 -45
  52. package/test/test.smb.node.ts +123 -0
  53. package/test/test.ssdp.node.ts +978 -0
  54. package/ts/00_commitinfo_data.ts +1 -1
  55. package/ts/device/device.classes.device.ts +2 -2
  56. package/ts/devicemanager.classes.devicemanager.ts +125 -12
  57. package/ts/discovery/discovery.classes.mdns.ts +40 -11
  58. package/ts/discovery/discovery.classes.networkscanner.ts +51 -2
  59. package/ts/discovery/discovery.classes.ssdp.ts +493 -130
  60. package/ts/events/events.observable.ts +31 -0
  61. package/ts/events/index.ts +4 -0
  62. package/ts/factories/index.ts +7 -5
  63. package/ts/features/feature.abstract.ts +2 -2
  64. package/ts/features/feature.print.ts +16 -1
  65. package/ts/features/feature.scan.ts +93 -27
  66. package/ts/index.ts +21 -0
  67. package/ts/interfaces/feature.interfaces.ts +23 -2
  68. package/ts/interfaces/index.ts +23 -4
  69. package/ts/plugins.ts +6 -25
  70. package/ts/protocols/index.ts +11 -1
  71. package/ts/protocols/protocol.brother.ts +648 -0
  72. package/ts/protocols/protocol.escl.ts +348 -191
  73. package/ts/protocols/protocol.ipp.ts +24 -1
  74. package/ts/providers/index.ts +7 -0
  75. package/ts/providers/provider.smb.ts +326 -0
  76. package/dist_ts/protocols/protocol.ipp.old.d.ts +0 -45
  77. package/dist_ts/protocols/protocol.ipp.old.js +0 -284
  78. package/npmextra.json +0 -24
@@ -164,6 +164,8 @@ export interface IIppPrinterCapabilities {
164
164
  // Document formats
165
165
  documentFormatSupported: string[];
166
166
  documentFormatDefault?: string;
167
+ /** Whether the printer advertises Apple's Universal Raster Format. */
168
+ airPrintSupported: boolean;
167
169
 
168
170
  // Media
169
171
  mediaSizeSupported: string[];
@@ -573,6 +575,10 @@ class IppDecoder {
573
575
  return date;
574
576
  }
575
577
 
578
+ case TAG_VALUE.TEXT_WITH_LANGUAGE:
579
+ case TAG_VALUE.NAME_WITH_LANGUAGE:
580
+ return this.readLanguageTaggedString(length);
581
+
576
582
  case TAG_VALUE.NO_VALUE:
577
583
  case TAG_VALUE.UNKNOWN:
578
584
  case TAG_VALUE.UNSUPPORTED:
@@ -584,6 +590,20 @@ class IppDecoder {
584
590
  return this.readString(length);
585
591
  }
586
592
  }
593
+
594
+ private readLanguageTaggedString(length: number): string {
595
+ const value = this.buffer.subarray(this.offset, this.offset + length);
596
+ this.offset += length;
597
+ if (value.length < 4) return value.toString('utf-8');
598
+
599
+ const languageLength = value.readUInt16BE(0);
600
+ const textLengthOffset = 2 + languageLength;
601
+ if (textLengthOffset + 2 > value.length) return value.toString('utf-8');
602
+ const textLength = value.readUInt16BE(textLengthOffset);
603
+ const textOffset = textLengthOffset + 2;
604
+ if (textOffset + textLength > value.length) return value.toString('utf-8');
605
+ return value.toString('utf-8', textOffset, textOffset + textLength);
606
+ }
587
607
  }
588
608
 
589
609
  // ============================================================================
@@ -745,6 +765,8 @@ export class IppClient {
745
765
  const printerStateString = printerState === PRINTER_STATE.IDLE ? 'idle' :
746
766
  printerState === PRINTER_STATE.PROCESSING ? 'processing' : 'stopped';
747
767
 
768
+ const documentFormatSupported = getStringArray('document-format-supported');
769
+
748
770
  return {
749
771
  printerName: getString('printer-name', 'Unknown Printer'),
750
772
  printerInfo: getString('printer-info') || undefined,
@@ -757,8 +779,9 @@ export class IppClient {
757
779
  printerIsAcceptingJobs: getBool('printer-is-accepting-jobs', true),
758
780
  queuedJobCount: getNumber('queued-job-count', 0),
759
781
 
760
- documentFormatSupported: getStringArray('document-format-supported'),
782
+ documentFormatSupported,
761
783
  documentFormatDefault: getString('document-format-default') || undefined,
784
+ airPrintSupported: documentFormatSupported.includes('image/urf'),
762
785
 
763
786
  mediaSizeSupported: getStringArray('media-supported'),
764
787
  mediaDefault: getString('media-default') || undefined,
@@ -0,0 +1,7 @@
1
+ export {
2
+ SmbShareProvider,
3
+ type ISmbDocumentEvent,
4
+ type ISmbServerAdapter,
5
+ type ISmbShareProviderDependencies,
6
+ type ISmbShareProviderOptions,
7
+ } from './provider.smb.js';
@@ -0,0 +1,326 @@
1
+ import {
2
+ SambaServer,
3
+ type ISambaAuthOptions,
4
+ type ISambaClientOptions,
5
+ type ISambaServerOptions,
6
+ type ISambaServerStartResult,
7
+ type ISambaServerStatus,
8
+ } from '@push.rocks/smartsamba';
9
+ import * as fs from 'node:fs/promises';
10
+ import * as path from 'node:path';
11
+ import * as plugins from '../plugins.js';
12
+ import { ObservableEventEmitter } from '../events/index.js';
13
+
14
+ export interface ISmbShareProviderOptions extends ISambaServerOptions {
15
+ /** Interval used to inspect share contents for changes. */
16
+ pollIntervalMs?: number;
17
+ /** Time a file must remain unchanged before it is emitted as ready. */
18
+ settleTimeMs?: number;
19
+ /** Emit pre-existing regular files after startup. Defaults to false. */
20
+ emitExistingDocuments?: boolean;
21
+ }
22
+
23
+ export interface ISmbDocumentEvent {
24
+ shareName: string;
25
+ relativePath: string;
26
+ absolutePath: string;
27
+ size: number;
28
+ createdAt: Date;
29
+ modifiedAt: Date;
30
+ detectedAt: Date;
31
+ }
32
+
33
+ export interface ISmbServerAdapter {
34
+ start(): Promise<ISambaServerStartResult>;
35
+ stop(): Promise<void>;
36
+ status(): Promise<ISambaServerStatus>;
37
+ getConnectionOptions(auth?: Partial<ISambaAuthOptions>): ISambaClientOptions;
38
+ }
39
+
40
+ export interface ISmbShareProviderDependencies {
41
+ serverFactory?: (options: ISambaServerOptions) => ISmbServerAdapter;
42
+ now?: () => number;
43
+ }
44
+
45
+ type TPendingFileEvent = 'arrived' | 'changed';
46
+
47
+ interface ITrackedFile {
48
+ shareName: string;
49
+ relativePath: string;
50
+ absolutePath: string;
51
+ size: number;
52
+ createdMs: number;
53
+ modifiedMs: number;
54
+ lastChangedMs: number;
55
+ emitted: boolean;
56
+ pending?: TPendingFileEvent;
57
+ }
58
+
59
+ const DEFAULT_POLL_INTERVAL_MS = 250;
60
+ const DEFAULT_SETTLE_TIME_MS = 1000;
61
+
62
+ /**
63
+ * Hosts local folders through SMB and emits settled document arrivals.
64
+ */
65
+ export class SmbShareProvider extends ObservableEventEmitter {
66
+ private readonly server: ISmbServerAdapter;
67
+ private readonly shares: ISambaServerOptions['shares'];
68
+ private readonly pollIntervalMs: number;
69
+ private readonly settleTimeMs: number;
70
+ private readonly emitExistingDocuments: boolean;
71
+ private readonly now: () => number;
72
+ private readonly trackedFiles = new Map<string, ITrackedFile>();
73
+ private pollTimer?: ReturnType<typeof setInterval>;
74
+ private scanPromise?: Promise<void>;
75
+ private startResult?: ISambaServerStartResult;
76
+
77
+ public readonly documents$ = this.events$.pipe(
78
+ plugins.rxjs.filter((event) => event.name === 'document:arrived'),
79
+ plugins.rxjs.map((event) => event.args[0] as ISmbDocumentEvent)
80
+ );
81
+
82
+ constructor(
83
+ options: ISmbShareProviderOptions,
84
+ dependencies: ISmbShareProviderDependencies = {}
85
+ ) {
86
+ super();
87
+ const {
88
+ pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
89
+ settleTimeMs = DEFAULT_SETTLE_TIME_MS,
90
+ emitExistingDocuments = false,
91
+ ...serverOptions
92
+ } = options;
93
+ if (
94
+ !Number.isFinite(pollIntervalMs) ||
95
+ !Number.isFinite(settleTimeMs) ||
96
+ pollIntervalMs <= 0 ||
97
+ settleTimeMs < 0
98
+ ) {
99
+ throw new Error('SMB poll interval must be positive and settle time must not be negative');
100
+ }
101
+ this.shares = serverOptions.shares;
102
+ this.pollIntervalMs = pollIntervalMs;
103
+ this.settleTimeMs = settleTimeMs;
104
+ this.emitExistingDocuments = emitExistingDocuments;
105
+ this.now = dependencies.now ?? Date.now;
106
+ this.server = dependencies.serverFactory?.(serverOptions) ?? new SambaServer(serverOptions);
107
+ }
108
+
109
+ public get running(): boolean {
110
+ return this.startResult !== undefined;
111
+ }
112
+
113
+ public async start(): Promise<ISambaServerStartResult> {
114
+ if (this.startResult) return this.startResult;
115
+
116
+ try {
117
+ await this.prepareShareDirectories();
118
+ await this.initializeTrackedFiles();
119
+ this.startResult = await this.server.start();
120
+ this.pollTimer = setInterval(() => this.queueScan(), this.pollIntervalMs);
121
+ this.pollTimer.unref?.();
122
+ this.emit('started', this.startResult);
123
+ return this.startResult;
124
+ } catch (error) {
125
+ this.emit('provider:error', error);
126
+ throw error;
127
+ }
128
+ }
129
+
130
+ public async stop(): Promise<void> {
131
+ if (this.pollTimer) {
132
+ clearInterval(this.pollTimer);
133
+ this.pollTimer = undefined;
134
+ }
135
+ await this.scanPromise;
136
+ if (!this.startResult) return;
137
+
138
+ try {
139
+ await this.server.stop();
140
+ } finally {
141
+ this.startResult = undefined;
142
+ this.trackedFiles.clear();
143
+ this.emit('stopped');
144
+ }
145
+ }
146
+
147
+ public async status(): Promise<ISambaServerStatus> {
148
+ if (!this.startResult) {
149
+ return { running: false, shares: this.shares.map((share) => share.name) };
150
+ }
151
+ return this.server.status();
152
+ }
153
+
154
+ public getConnectionOptions(auth?: Partial<ISambaAuthOptions>): ISambaClientOptions {
155
+ return this.server.getConnectionOptions(auth);
156
+ }
157
+
158
+ /** Runs an immediate share scan. Useful after an external notification. */
159
+ public async scanNow(): Promise<void> {
160
+ await this.queueScan();
161
+ }
162
+
163
+ private async prepareShareDirectories(): Promise<void> {
164
+ for (const share of this.shares) {
165
+ if (share.createIfMissing !== false) {
166
+ await fs.mkdir(path.resolve(share.path), { recursive: true });
167
+ }
168
+ }
169
+ }
170
+
171
+ private async initializeTrackedFiles(): Promise<void> {
172
+ this.trackedFiles.clear();
173
+ const now = this.now();
174
+ for (const share of this.shares) {
175
+ const files = await listRegularFiles(path.resolve(share.path));
176
+ for (const file of files) {
177
+ const tracked = this.toTrackedFile(share.name, file, now);
178
+ tracked.emitted = !this.emitExistingDocuments;
179
+ tracked.pending = this.emitExistingDocuments ? 'arrived' : undefined;
180
+ this.trackedFiles.set(trackedFileKey(share.name, file.absolutePath), tracked);
181
+ }
182
+ }
183
+ }
184
+
185
+ private queueScan(): Promise<void> {
186
+ if (this.scanPromise) return this.scanPromise;
187
+ const scanPromise = this.scanShares()
188
+ .catch((error) => {
189
+ this.emit('provider:error', error);
190
+ })
191
+ .finally(() => {
192
+ this.scanPromise = undefined;
193
+ });
194
+ this.scanPromise = scanPromise;
195
+ return scanPromise;
196
+ }
197
+
198
+ private async scanShares(): Promise<void> {
199
+ const now = this.now();
200
+ const seenFiles = new Set<string>();
201
+
202
+ for (const share of this.shares) {
203
+ const rootPath = path.resolve(share.path);
204
+ const files = await listRegularFiles(rootPath);
205
+ for (const file of files) {
206
+ const key = trackedFileKey(share.name, file.absolutePath);
207
+ seenFiles.add(key);
208
+ const existing = this.trackedFiles.get(key);
209
+ if (!existing) {
210
+ this.trackedFiles.set(
211
+ key,
212
+ this.toTrackedFile(share.name, file, now, 'arrived')
213
+ );
214
+ continue;
215
+ }
216
+
217
+ if (existing.size !== file.size || existing.modifiedMs !== file.modifiedMs) {
218
+ existing.size = file.size;
219
+ existing.createdMs = file.createdMs;
220
+ existing.modifiedMs = file.modifiedMs;
221
+ existing.lastChangedMs = now;
222
+ existing.pending = existing.emitted ? 'changed' : 'arrived';
223
+ continue;
224
+ }
225
+
226
+ if (existing.pending && now - existing.lastChangedMs >= this.settleTimeMs) {
227
+ const eventName = existing.pending === 'arrived'
228
+ ? 'document:arrived'
229
+ : 'document:changed';
230
+ existing.pending = undefined;
231
+ existing.emitted = true;
232
+ this.emit(eventName, this.toDocumentEvent(existing, now));
233
+ }
234
+ }
235
+ }
236
+
237
+ for (const [key, tracked] of this.trackedFiles) {
238
+ if (seenFiles.has(key)) continue;
239
+ this.trackedFiles.delete(key);
240
+ if (tracked.emitted) {
241
+ this.emit('document:removed', this.toDocumentEvent(tracked, now));
242
+ }
243
+ }
244
+ }
245
+
246
+ private toTrackedFile(
247
+ shareName: string,
248
+ file: IRegularFile,
249
+ now: number,
250
+ pending?: TPendingFileEvent
251
+ ): ITrackedFile {
252
+ return {
253
+ shareName,
254
+ relativePath: file.relativePath,
255
+ absolutePath: file.absolutePath,
256
+ size: file.size,
257
+ createdMs: file.createdMs,
258
+ modifiedMs: file.modifiedMs,
259
+ lastChangedMs: now,
260
+ emitted: false,
261
+ pending,
262
+ };
263
+ }
264
+
265
+ private toDocumentEvent(file: ITrackedFile, now: number): ISmbDocumentEvent {
266
+ return {
267
+ shareName: file.shareName,
268
+ relativePath: file.relativePath,
269
+ absolutePath: file.absolutePath,
270
+ size: file.size,
271
+ createdAt: new Date(file.createdMs),
272
+ modifiedAt: new Date(file.modifiedMs),
273
+ detectedAt: new Date(now),
274
+ };
275
+ }
276
+ }
277
+
278
+ interface IRegularFile {
279
+ relativePath: string;
280
+ absolutePath: string;
281
+ size: number;
282
+ createdMs: number;
283
+ modifiedMs: number;
284
+ }
285
+
286
+ function trackedFileKey(shareName: string, absolutePath: string): string {
287
+ return `${shareName}\0${absolutePath}`;
288
+ }
289
+
290
+ async function listRegularFiles(rootPath: string): Promise<IRegularFile[]> {
291
+ const files: IRegularFile[] = [];
292
+ await walkDirectory(rootPath, rootPath, files);
293
+ return files;
294
+ }
295
+
296
+ async function walkDirectory(
297
+ rootPath: string,
298
+ directoryPath: string,
299
+ files: IRegularFile[]
300
+ ): Promise<void> {
301
+ let entries;
302
+ try {
303
+ entries = await fs.readdir(directoryPath, { withFileTypes: true });
304
+ } catch (error) {
305
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
306
+ throw error;
307
+ }
308
+
309
+ for (const entry of entries) {
310
+ const absolutePath = path.join(directoryPath, entry.name);
311
+ if (entry.isDirectory()) {
312
+ await walkDirectory(rootPath, absolutePath, files);
313
+ continue;
314
+ }
315
+ if (!entry.isFile()) continue;
316
+
317
+ const stats = await fs.stat(absolutePath);
318
+ files.push({
319
+ relativePath: path.relative(rootPath, absolutePath).split(path.sep).join('/'),
320
+ absolutePath,
321
+ size: stats.size,
322
+ createdMs: stats.birthtimeMs,
323
+ modifiedMs: stats.mtimeMs,
324
+ });
325
+ }
326
+ }
@@ -1,45 +0,0 @@
1
- import type { IPrinterCapabilities, IPrintOptions, IPrintJob } from '../interfaces/index.js';
2
- /**
3
- * IPP protocol wrapper using the ipp npm package
4
- */
5
- export declare class IppProtocol {
6
- private printerUrl;
7
- private printer;
8
- constructor(address: string, port: number, path?: string);
9
- /**
10
- * Get printer attributes/capabilities
11
- */
12
- getAttributes(): Promise<IPrinterCapabilities>;
13
- /**
14
- * Print a document
15
- */
16
- print(data: Buffer, options?: IPrintOptions): Promise<IPrintJob>;
17
- /**
18
- * Get all jobs
19
- */
20
- getJobs(): Promise<IPrintJob[]>;
21
- /**
22
- * Get specific job info
23
- */
24
- getJobInfo(jobId: number): Promise<IPrintJob>;
25
- /**
26
- * Cancel a job
27
- */
28
- cancelJob(jobId: number): Promise<void>;
29
- /**
30
- * Check if printer is available
31
- */
32
- checkAvailability(): Promise<boolean>;
33
- /**
34
- * Build IPP print message from options
35
- */
36
- private buildPrintMessage;
37
- /**
38
- * Parse printer capabilities from attributes
39
- */
40
- private parseCapabilities;
41
- /**
42
- * Parse job info from attributes
43
- */
44
- private parseJobInfo;
45
- }