@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
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@ecobridge.xyz/devicemanager',
6
- version: '3.1.0',
6
+ version: '3.2.0',
7
7
  description: 'a device manager for talking to devices on network and over usb'
8
8
  }
@@ -3,7 +3,6 @@
3
3
  * A device is a network endpoint that can have multiple features (capabilities)
4
4
  */
5
5
 
6
- import * as plugins from '../plugins.js';
7
6
  import type {
8
7
  TFeatureType,
9
8
  IFeatureInfo,
@@ -11,6 +10,7 @@ import type {
11
10
  } from '../interfaces/feature.interfaces.js';
12
11
  import type { TDeviceStatus, IRetryOptions } from '../interfaces/index.js';
13
12
  import { Feature, type TDeviceReference } from '../features/feature.abstract.js';
13
+ import { ObservableEventEmitter } from '../events/index.js';
14
14
 
15
15
  // ============================================================================
16
16
  // Device Info Interface
@@ -69,7 +69,7 @@ export interface IDeviceCreateOptions {
69
69
  * }
70
70
  * ```
71
71
  */
72
- export class UniversalDevice extends plugins.events.EventEmitter {
72
+ export class UniversalDevice extends ObservableEventEmitter {
73
73
  // ============================================================================
74
74
  // Properties
75
75
  // ============================================================================
@@ -40,6 +40,12 @@ import type {
40
40
  TFeatureType,
41
41
  IDeviceSelector,
42
42
  } from './interfaces/index.js';
43
+ import { ObservableEventEmitter } from './events/index.js';
44
+ import {
45
+ SmbShareProvider,
46
+ type ISmbShareProviderDependencies,
47
+ type ISmbShareProviderOptions,
48
+ } from './providers/index.js';
43
49
 
44
50
  /**
45
51
  * Default device manager options
@@ -132,10 +138,12 @@ function makeDeviceRef(address: string, port: number, name: string): { id: strin
132
138
  * via multiple methods (mDNS, SSDP, IP scan), features are merged into a
133
139
  * single UniversalDevice instance.
134
140
  */
135
- export class DeviceManager extends plugins.events.EventEmitter {
141
+ export class DeviceManager extends ObservableEventEmitter {
136
142
  private mdnsDiscovery: MdnsDiscovery;
137
143
  private ssdpDiscovery: SsdpDiscovery;
138
144
  private _networkScanner: NetworkScanner | null = null;
145
+ private readonly smbProviders = new Set<SmbShareProvider>();
146
+ private readonly smbProviderSubscriptions = new Map<SmbShareProvider, plugins.rxjs.Subscription>();
139
147
 
140
148
  // Devices keyed by IP address for deduplication
141
149
  private devicesByIp: Map<string, UniversalDevice> = new Map();
@@ -546,17 +554,55 @@ export class DeviceManager extends plugins.events.EventEmitter {
546
554
  // ============================================================================
547
555
 
548
556
  public async startDiscovery(): Promise<void> {
549
- await Promise.all([
550
- this.mdnsDiscovery.start(),
551
- this.ssdpDiscovery.start(),
557
+ const startResults = await Promise.allSettled([
558
+ Promise.resolve().then(() => this.mdnsDiscovery.start()),
559
+ Promise.resolve().then(() => this.ssdpDiscovery.start()),
560
+ ]);
561
+ const startupFailures = startResults
562
+ .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
563
+ .map((result) => result.reason as unknown);
564
+ if (startupFailures.length === 0) {
565
+ return;
566
+ }
567
+
568
+ const cleanupResults = await Promise.allSettled([
569
+ Promise.resolve().then(() => this.mdnsDiscovery.stop()),
570
+ Promise.resolve().then(() => this.ssdpDiscovery.stop()),
552
571
  ]);
572
+ const cleanupFailures = cleanupResults
573
+ .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
574
+ .map((result) => result.reason as unknown);
575
+
576
+ if (startupFailures.length === 1 && cleanupFailures.length === 0) {
577
+ throw startupFailures[0];
578
+ }
579
+
580
+ const message = cleanupFailures.length > 0
581
+ ? 'Device discovery failed to start and rollback cleanup was incomplete.'
582
+ : 'Multiple discovery transports failed to start.';
583
+ throw new AggregateError(
584
+ [...startupFailures, ...cleanupFailures],
585
+ message,
586
+ { cause: startupFailures[0] }
587
+ );
553
588
  }
554
589
 
555
590
  public async stopDiscovery(): Promise<void> {
556
- await Promise.all([
557
- this.mdnsDiscovery.stop(),
558
- this.ssdpDiscovery.stop(),
591
+ const stopResults = await Promise.allSettled([
592
+ Promise.resolve().then(() => this.mdnsDiscovery.stop()),
593
+ Promise.resolve().then(() => this.ssdpDiscovery.stop()),
559
594
  ]);
595
+ const stopFailures = stopResults
596
+ .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
597
+ .map((result) => result.reason as unknown);
598
+
599
+ if (stopFailures.length > 0) {
600
+ throw new AggregateError(
601
+ stopFailures,
602
+ 'One or more discovery transports failed to stop cleanly.',
603
+ { cause: stopFailures[0] }
604
+ );
605
+ }
560
606
  }
561
607
 
562
608
  public get isDiscovering(): boolean {
@@ -734,6 +780,61 @@ export class DeviceManager extends plugins.events.EventEmitter {
734
780
  return this.getDevicesWithFeature('snmp');
735
781
  }
736
782
 
783
+ // ============================================================================
784
+ // Host-side SMB providers
785
+ // ============================================================================
786
+
787
+ /**
788
+ * Start an SMB server and forward its events through this manager as `smb:*`.
789
+ */
790
+ public async provideSmbShares(
791
+ options: ISmbShareProviderOptions,
792
+ dependencies?: ISmbShareProviderDependencies
793
+ ): Promise<SmbShareProvider> {
794
+ const provider = new SmbShareProvider(options, dependencies);
795
+ const subscription = provider.events$.subscribe((event) => {
796
+ this.emit(`smb:${event.name}`, ...event.args);
797
+ });
798
+
799
+ try {
800
+ await provider.start();
801
+ this.smbProviders.add(provider);
802
+ this.smbProviderSubscriptions.set(provider, subscription);
803
+ return provider;
804
+ } catch (error) {
805
+ subscription.unsubscribe();
806
+ throw error;
807
+ }
808
+ }
809
+
810
+ /** Stop one SMB provider created by this manager. */
811
+ public async stopSmbShareProvider(provider: SmbShareProvider): Promise<void> {
812
+ try {
813
+ await provider.stop();
814
+ } finally {
815
+ this.smbProviderSubscriptions.get(provider)?.unsubscribe();
816
+ this.smbProviderSubscriptions.delete(provider);
817
+ this.smbProviders.delete(provider);
818
+ }
819
+ }
820
+
821
+ /** Stop every SMB provider owned by this manager. */
822
+ public async stopAllSmbShareProviders(): Promise<void> {
823
+ const results = await Promise.allSettled(
824
+ [...this.smbProviders].map((provider) => this.stopSmbShareProvider(provider))
825
+ );
826
+ const failures = results
827
+ .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
828
+ .map((result) => result.reason as unknown);
829
+ if (failures.length > 0) {
830
+ throw new AggregateError(failures, 'One or more SMB providers failed to stop cleanly.');
831
+ }
832
+ }
833
+
834
+ public getSmbShareProviders(): SmbShareProvider[] {
835
+ return [...this.smbProviders];
836
+ }
837
+
737
838
  // ============================================================================
738
839
  // Manual Device Addition
739
840
  // ============================================================================
@@ -769,7 +870,7 @@ export class DeviceManager extends plugins.events.EventEmitter {
769
870
  public async addScanner(
770
871
  address: string,
771
872
  port: number,
772
- protocol: 'escl' | 'sane' = 'escl',
873
+ protocol: 'brother' | 'escl' | 'sane' = 'escl',
773
874
  name?: string
774
875
  ): Promise<UniversalDevice> {
775
876
  const feature = new ScanFeature(
@@ -1033,7 +1134,7 @@ export class DeviceManager extends plugins.events.EventEmitter {
1033
1134
  device = await this.addScanner(
1034
1135
  result.address,
1035
1136
  deviceInfo.port,
1036
- deviceInfo.protocol as 'escl' | 'sane',
1137
+ deviceInfo.protocol as 'brother' | 'escl' | 'sane',
1037
1138
  deviceInfo.name
1038
1139
  );
1039
1140
  } else if (deviceInfo.type === 'printer' && deviceInfo.protocol === 'ipp') {
@@ -1060,7 +1161,8 @@ export class DeviceManager extends plugins.events.EventEmitter {
1060
1161
 
1061
1162
  /**
1062
1163
  * Discover scanners in a subnet and add them to the manager.
1063
- * Convenience method that focuses discovery on scanner protocols only (eSCL, SANE).
1164
+ * Convenience method that focuses discovery on scanner protocols only
1165
+ * (Brother native, eSCL/AirScan, and SANE).
1064
1166
  *
1065
1167
  * @param subnet CIDR notation subnet (e.g., '192.168.1.0/24')
1066
1168
  * @param options Optional discovery settings
@@ -1080,6 +1182,7 @@ export class DeviceManager extends plugins.events.EventEmitter {
1080
1182
  ): Promise<UniversalDevice[]> {
1081
1183
  await this.scanNetwork({
1082
1184
  ipRange: subnet,
1185
+ probeBrother: true,
1083
1186
  probeEscl: true,
1084
1187
  probeSane: true,
1085
1188
  probeIpp: false,
@@ -1107,6 +1210,7 @@ export class DeviceManager extends plugins.events.EventEmitter {
1107
1210
  ): Promise<UniversalDevice[]> {
1108
1211
  await this.scanNetwork({
1109
1212
  ipRange: subnet,
1213
+ probeBrother: false,
1110
1214
  probeEscl: false,
1111
1215
  probeSane: false,
1112
1216
  probeIpp: true,
@@ -1179,10 +1283,19 @@ export class DeviceManager extends plugins.events.EventEmitter {
1179
1283
  * Stop discovery and disconnect all devices
1180
1284
  */
1181
1285
  public async shutdown(): Promise<void> {
1182
- await this.stopDiscovery();
1183
- await this.disconnectAll();
1286
+ const results = await Promise.allSettled([
1287
+ this.stopDiscovery(),
1288
+ this.stopAllSmbShareProviders(),
1289
+ this.disconnectAll(),
1290
+ ]);
1184
1291
  this.devicesByIp.clear();
1185
1292
  this.nameSourceByIp.clear();
1293
+ const failures = results
1294
+ .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
1295
+ .map((result) => result.reason as unknown);
1296
+ if (failures.length > 0) {
1297
+ throw new AggregateError(failures, 'DeviceManager shutdown was incomplete.');
1298
+ }
1186
1299
  }
1187
1300
  }
1188
1301
 
@@ -117,20 +117,49 @@ export class MdnsDiscovery extends plugins.events.EventEmitter {
117
117
  return;
118
118
  }
119
119
 
120
- // Stop all browsers
121
- for (const browser of this.browsers) {
122
- browser.stop();
123
- }
120
+ const browsers = this.browsers;
121
+ const bonjour = this.bonjour;
122
+ const cleanupErrors: unknown[] = [];
124
123
  this.browsers = [];
125
-
126
- // Destroy bonjour instance
127
- if (this.bonjour) {
128
- this.bonjour.destroy();
129
- this.bonjour = null;
124
+ this.bonjour = null;
125
+
126
+ try {
127
+ for (const browser of browsers) {
128
+ try {
129
+ browser.stop();
130
+ } catch (error) {
131
+ cleanupErrors.push(error);
132
+ }
133
+ }
134
+ } finally {
135
+ try {
136
+ if (bonjour) {
137
+ await new Promise<void>((resolve, reject) => {
138
+ try {
139
+ bonjour.destroy((error?: Error) => error ? reject(error) : resolve());
140
+ } catch (error) {
141
+ reject(error);
142
+ }
143
+ });
144
+ }
145
+ } catch (error) {
146
+ cleanupErrors.push(error);
147
+ } finally {
148
+ this.isRunning = false;
149
+ try {
150
+ this.emit('stopped');
151
+ } catch (error) {
152
+ cleanupErrors.push(error);
153
+ }
154
+ }
130
155
  }
131
156
 
132
- this.isRunning = false;
133
- this.emit('stopped');
157
+ if (cleanupErrors.length === 1) {
158
+ throw cleanupErrors[0];
159
+ }
160
+ if (cleanupErrors.length > 1) {
161
+ throw new AggregateError(cleanupErrors, 'Failed to stop mDNS discovery cleanly.');
162
+ }
134
163
  }
135
164
 
136
165
  /**
@@ -1,5 +1,5 @@
1
1
  import * as plugins from '../plugins.js';
2
- import { EsclProtocol } from '../protocols/index.js';
2
+ import { BrotherScanProtocol, EsclProtocol } from '../protocols/index.js';
3
3
  import {
4
4
  cidrToIps,
5
5
  ipRangeToIps,
@@ -19,6 +19,7 @@ const DEFAULT_PORTS = [
19
19
  631, // IPP printers
20
20
  80, // eSCL scanners (HTTP)
21
21
  443, // eSCL scanners (HTTPS)
22
+ 54921, // Brother native network scanners
22
23
  6566, // SANE scanners
23
24
  9100, // JetDirect printers
24
25
  7000, // AirPlay speakers
@@ -35,6 +36,7 @@ const DEFAULT_OPTIONS: Required<Omit<INetworkScanOptions, 'ipRange' | 'startIp'
35
36
  concurrency: 50,
36
37
  timeout: 2000,
37
38
  ports: DEFAULT_PORTS,
39
+ probeBrother: true,
38
40
  probeEscl: true,
39
41
  probeIpp: true,
40
42
  probeSane: true,
@@ -205,6 +207,15 @@ export class NetworkScanner extends plugins.events.EventEmitter {
205
207
  const probePromises: Promise<void>[] = [];
206
208
 
207
209
  for (const port of openPorts) {
210
+ // Brother native scan service
211
+ if (opts.probeBrother && port === 54921) {
212
+ probePromises.push(
213
+ this.probeBrother(ip, port, timeout).then((device) => {
214
+ if (device) devices.push(device);
215
+ })
216
+ );
217
+ }
218
+
208
219
  // IPP probe (port 631)
209
220
  if (opts.probeIpp && port === 631) {
210
221
  probePromises.push(
@@ -282,6 +293,17 @@ export class NetworkScanner extends plugins.events.EventEmitter {
282
293
 
283
294
  await Promise.all(probePromises);
284
295
 
296
+ // Prefer a vendor transport that enforces scan settings over standards
297
+ // endpoints whose firmware may silently downgrade those settings.
298
+ const protocolPriority: Partial<Record<INetworkScanDevice['protocol'], number>> = {
299
+ brother: 0,
300
+ escl: 1,
301
+ sane: 2,
302
+ };
303
+ devices.sort((left, right) =>
304
+ (protocolPriority[left.protocol] ?? 10) - (protocolPriority[right.protocol] ?? 10)
305
+ );
306
+
285
307
  return devices;
286
308
  }
287
309
 
@@ -330,9 +352,36 @@ export class NetworkScanner extends plugins.events.EventEmitter {
330
352
  });
331
353
  }
332
354
 
355
+ /**
356
+ * Probe for Brother's native network scan service.
357
+ */
358
+ private async probeBrother(
359
+ ip: string,
360
+ port: number,
361
+ timeout: number
362
+ ): Promise<INetworkScanDevice | null> {
363
+ const brother = new BrotherScanProtocol(ip, port, { timeout, busyTimeout: 0 });
364
+ try {
365
+ await brother.connect();
366
+ return {
367
+ type: 'scanner',
368
+ protocol: 'brother',
369
+ port,
370
+ name: `Brother Scanner at ${ip}`,
371
+ model: undefined,
372
+ };
373
+ } catch {
374
+ // A busy endpoint is not selected because connection would fail; eSCL
375
+ // can still be discovered as a fallback on the same device.
376
+ return null;
377
+ } finally {
378
+ await brother.disconnect();
379
+ }
380
+ }
381
+
333
382
  /**
334
383
  * Probe for IPP printer using a simple HTTP check
335
- * We avoid using the full ipp library for probing since it can hang and produce noisy output
384
+ * Use a minimal request instead of an external IPP client for discovery probing.
336
385
  */
337
386
  private async probeIpp(
338
387
  ip: string,