@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,376 @@
1
+ import * as plugins from '../plugins.js';
2
+ import type {
3
+ IHomeAssistantInstanceConfig,
4
+ IHomeAssistantEntity,
5
+ IHomeAssistantDiscoveredInstance,
6
+ THomeAssistantDomain,
7
+ THomeAssistantDiscoveryEvents,
8
+ } from '../interfaces/homeassistant.interfaces.js';
9
+ import { HomeAssistantProtocol } from '../protocols/protocol.homeassistant.js';
10
+
11
+ /**
12
+ * mDNS service type for Home Assistant discovery
13
+ */
14
+ const HA_SERVICE_TYPE = '_home-assistant._tcp';
15
+
16
+ /**
17
+ * Default domains to discover
18
+ */
19
+ const DEFAULT_DOMAINS: THomeAssistantDomain[] = [
20
+ 'light',
21
+ 'switch',
22
+ 'sensor',
23
+ 'binary_sensor',
24
+ 'climate',
25
+ 'fan',
26
+ 'cover',
27
+ 'lock',
28
+ 'camera',
29
+ 'media_player',
30
+ ];
31
+
32
+ /**
33
+ * Home Assistant Discovery
34
+ * Discovers HA instances via mDNS and/or manual configuration,
35
+ * connects to them, and enumerates all entities
36
+ */
37
+ export class HomeAssistantDiscovery extends plugins.events.EventEmitter {
38
+ private bonjour: plugins.bonjourService.Bonjour | null = null;
39
+ private browser: plugins.bonjourService.Browser | null = null;
40
+ private discoveredInstances: Map<string, IHomeAssistantDiscoveredInstance> = new Map();
41
+ private connectedProtocols: Map<string, HomeAssistantProtocol> = new Map();
42
+ private entityCache: Map<string, IHomeAssistantEntity> = new Map();
43
+ private enabledDomains: THomeAssistantDomain[];
44
+ private isRunning: boolean = false;
45
+
46
+ constructor(options?: { enabledDomains?: THomeAssistantDomain[] }) {
47
+ super();
48
+ this.enabledDomains = options?.enabledDomains || DEFAULT_DOMAINS;
49
+ }
50
+
51
+ /**
52
+ * Check if discovery is running
53
+ */
54
+ public get running(): boolean {
55
+ return this.isRunning;
56
+ }
57
+
58
+ /**
59
+ * Get all discovered HA instances
60
+ */
61
+ public getInstances(): IHomeAssistantDiscoveredInstance[] {
62
+ return Array.from(this.discoveredInstances.values());
63
+ }
64
+
65
+ /**
66
+ * Get connected protocol for an instance
67
+ */
68
+ public getProtocol(instanceId: string): HomeAssistantProtocol | undefined {
69
+ return this.connectedProtocols.get(instanceId);
70
+ }
71
+
72
+ /**
73
+ * Get all connected protocols
74
+ */
75
+ public getProtocols(): Map<string, HomeAssistantProtocol> {
76
+ return this.connectedProtocols;
77
+ }
78
+
79
+ /**
80
+ * Get all cached entities
81
+ */
82
+ public getEntities(): IHomeAssistantEntity[] {
83
+ return Array.from(this.entityCache.values());
84
+ }
85
+
86
+ /**
87
+ * Get entities by domain
88
+ */
89
+ public getEntitiesByDomain(domain: THomeAssistantDomain): IHomeAssistantEntity[] {
90
+ return this.getEntities().filter((e) => e.entity_id.startsWith(`${domain}.`));
91
+ }
92
+
93
+ /**
94
+ * Get entities for a specific instance
95
+ */
96
+ public getEntitiesForInstance(instanceId: string): IHomeAssistantEntity[] {
97
+ const protocol = this.connectedProtocols.get(instanceId);
98
+ if (!protocol) return [];
99
+ return Array.from(protocol.entities.values());
100
+ }
101
+
102
+ /**
103
+ * Start mDNS discovery for Home Assistant instances
104
+ */
105
+ public async startMdnsDiscovery(): Promise<void> {
106
+ if (this.isRunning) {
107
+ return;
108
+ }
109
+
110
+ this.bonjour = new plugins.bonjourService.Bonjour();
111
+ this.isRunning = true;
112
+
113
+ this.browser = this.bonjour.find({ type: HA_SERVICE_TYPE }, (service) => {
114
+ this.handleInstanceFound(service);
115
+ });
116
+
117
+ this.browser.on('down', (service) => {
118
+ this.handleInstanceLost(service);
119
+ });
120
+ }
121
+
122
+ /**
123
+ * Stop mDNS discovery
124
+ */
125
+ public async stopMdnsDiscovery(): Promise<void> {
126
+ if (!this.isRunning) {
127
+ return;
128
+ }
129
+
130
+ if (this.browser) {
131
+ this.browser.stop();
132
+ this.browser = null;
133
+ }
134
+
135
+ if (this.bonjour) {
136
+ this.bonjour.destroy();
137
+ this.bonjour = null;
138
+ }
139
+
140
+ this.isRunning = false;
141
+ }
142
+
143
+ /**
144
+ * Add a manually configured HA instance
145
+ */
146
+ public async addInstance(config: IHomeAssistantInstanceConfig): Promise<HomeAssistantProtocol> {
147
+ const instanceId = this.generateInstanceId(config.host, config.port || 8123);
148
+
149
+ // Check if already connected
150
+ if (this.connectedProtocols.has(instanceId)) {
151
+ return this.connectedProtocols.get(instanceId)!;
152
+ }
153
+
154
+ // Create protocol and connect
155
+ const protocol = new HomeAssistantProtocol(config);
156
+
157
+ // Set up event handlers
158
+ this.setupProtocolHandlers(protocol, instanceId);
159
+
160
+ // Connect
161
+ await protocol.connect();
162
+
163
+ // Subscribe to state changes
164
+ await protocol.subscribeToStateChanges();
165
+
166
+ // Cache entities
167
+ const entities = await protocol.getStates();
168
+ for (const entity of entities) {
169
+ if (this.isEnabledDomain(entity.entity_id)) {
170
+ const cacheKey = `${instanceId}:${entity.entity_id}`;
171
+ this.entityCache.set(cacheKey, entity);
172
+ this.emit('entity:found', entity);
173
+ }
174
+ }
175
+
176
+ // Store protocol
177
+ this.connectedProtocols.set(instanceId, protocol);
178
+
179
+ // Also store as discovered instance
180
+ this.discoveredInstances.set(instanceId, {
181
+ id: instanceId,
182
+ host: config.host,
183
+ port: config.port || 8123,
184
+ base_url: `http://${config.host}:${config.port || 8123}`,
185
+ txtRecords: {},
186
+ requires_api_password: true,
187
+ friendlyName: config.friendlyName,
188
+ });
189
+
190
+ return protocol;
191
+ }
192
+
193
+ /**
194
+ * Remove an HA instance
195
+ */
196
+ public async removeInstance(instanceId: string): Promise<void> {
197
+ const protocol = this.connectedProtocols.get(instanceId);
198
+ if (protocol) {
199
+ await protocol.disconnect();
200
+ this.connectedProtocols.delete(instanceId);
201
+ }
202
+
203
+ this.discoveredInstances.delete(instanceId);
204
+
205
+ // Remove cached entities for this instance
206
+ for (const key of this.entityCache.keys()) {
207
+ if (key.startsWith(`${instanceId}:`)) {
208
+ this.entityCache.delete(key);
209
+ }
210
+ }
211
+
212
+ this.emit('instance:lost', instanceId);
213
+ }
214
+
215
+ /**
216
+ * Stop all and cleanup
217
+ */
218
+ public async stop(): Promise<void> {
219
+ await this.stopMdnsDiscovery();
220
+
221
+ // Disconnect all protocols
222
+ for (const [instanceId, protocol] of this.connectedProtocols) {
223
+ await protocol.disconnect();
224
+ }
225
+ this.connectedProtocols.clear();
226
+ this.discoveredInstances.clear();
227
+ this.entityCache.clear();
228
+ }
229
+
230
+ /**
231
+ * Handle mDNS service found
232
+ */
233
+ private handleInstanceFound(service: plugins.bonjourService.Service): void {
234
+ const addresses = service.addresses ?? [];
235
+ const address = addresses.find((a) => !a.includes(':')) ?? addresses[0] ?? service.host;
236
+
237
+ if (!address) {
238
+ return;
239
+ }
240
+
241
+ const instanceId = this.generateInstanceId(address, service.port);
242
+ const txtRecords = this.parseTxtRecords(service.txt);
243
+
244
+ const instance: IHomeAssistantDiscoveredInstance = {
245
+ id: instanceId,
246
+ host: address,
247
+ port: service.port,
248
+ base_url: txtRecords['base_url'] || `http://${address}:${service.port}`,
249
+ txtRecords,
250
+ requires_api_password: txtRecords['requires_api_password'] === 'true',
251
+ friendlyName: service.name,
252
+ };
253
+
254
+ // Check if this is a new instance
255
+ const existing = this.discoveredInstances.get(instanceId);
256
+ if (!existing) {
257
+ this.discoveredInstances.set(instanceId, instance);
258
+ this.emit('instance:found', instance);
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Handle mDNS service lost
264
+ */
265
+ private handleInstanceLost(service: plugins.bonjourService.Service): void {
266
+ const addresses = service.addresses ?? [];
267
+ const address = addresses.find((a) => !a.includes(':')) ?? addresses[0] ?? service.host;
268
+
269
+ if (!address) {
270
+ return;
271
+ }
272
+
273
+ const instanceId = this.generateInstanceId(address, service.port);
274
+
275
+ if (this.discoveredInstances.has(instanceId)) {
276
+ // Don't remove if we have an active connection (manually added)
277
+ if (!this.connectedProtocols.has(instanceId)) {
278
+ this.discoveredInstances.delete(instanceId);
279
+ }
280
+ this.emit('instance:lost', instanceId);
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Set up event handlers for a protocol
286
+ */
287
+ private setupProtocolHandlers(protocol: HomeAssistantProtocol, instanceId: string): void {
288
+ protocol.on('state:changed', (event) => {
289
+ const cacheKey = `${instanceId}:${event.entity_id}`;
290
+
291
+ if (event.new_state) {
292
+ if (this.isEnabledDomain(event.entity_id)) {
293
+ const existing = this.entityCache.has(cacheKey);
294
+ this.entityCache.set(cacheKey, event.new_state);
295
+
296
+ if (existing) {
297
+ this.emit('entity:updated', event.new_state);
298
+ } else {
299
+ this.emit('entity:found', event.new_state);
300
+ }
301
+ }
302
+ } else {
303
+ // Entity removed
304
+ if (this.entityCache.has(cacheKey)) {
305
+ this.entityCache.delete(cacheKey);
306
+ this.emit('entity:removed', event.entity_id);
307
+ }
308
+ }
309
+ });
310
+
311
+ protocol.on('disconnected', () => {
312
+ // Clear cached entities for this instance on disconnect
313
+ for (const key of this.entityCache.keys()) {
314
+ if (key.startsWith(`${instanceId}:`)) {
315
+ this.entityCache.delete(key);
316
+ }
317
+ }
318
+ });
319
+
320
+ protocol.on('error', (error) => {
321
+ this.emit('error', error);
322
+ });
323
+ }
324
+
325
+ /**
326
+ * Check if entity domain is enabled
327
+ */
328
+ private isEnabledDomain(entityId: string): boolean {
329
+ const domain = entityId.split('.')[0] as THomeAssistantDomain;
330
+ return this.enabledDomains.includes(domain);
331
+ }
332
+
333
+ /**
334
+ * Generate unique instance ID
335
+ */
336
+ private generateInstanceId(host: string, port: number): string {
337
+ return `ha:${host}:${port}`;
338
+ }
339
+
340
+ /**
341
+ * Parse TXT records from mDNS service
342
+ */
343
+ private parseTxtRecords(txt: Record<string, unknown> | undefined): Record<string, string> {
344
+ const records: Record<string, string> = {};
345
+
346
+ if (!txt) {
347
+ return records;
348
+ }
349
+
350
+ for (const [key, value] of Object.entries(txt)) {
351
+ if (typeof value === 'string') {
352
+ records[key] = value;
353
+ } else if (Buffer.isBuffer(value)) {
354
+ records[key] = value.toString('utf-8');
355
+ } else if (value !== undefined && value !== null) {
356
+ records[key] = String(value);
357
+ }
358
+ }
359
+
360
+ return records;
361
+ }
362
+
363
+ /**
364
+ * Probe if a host has Home Assistant running
365
+ */
366
+ public static async probe(
367
+ host: string,
368
+ port: number = 8123,
369
+ secure: boolean = false,
370
+ timeout: number = 5000
371
+ ): Promise<boolean> {
372
+ return HomeAssistantProtocol.probe(host, port, secure, timeout);
373
+ }
374
+ }
375
+
376
+ export { HA_SERVICE_TYPE };
@@ -0,0 +1,347 @@
1
+ import * as plugins from '../plugins.js';
2
+ import type {
3
+ IDiscoveredDevice,
4
+ IDiscoveryOptions,
5
+ TDeviceType,
6
+ TScannerProtocol,
7
+ } from '../interfaces/index.js';
8
+
9
+ /**
10
+ * Service type definitions for mDNS discovery
11
+ */
12
+ const SERVICE_TYPES = {
13
+ // Scanners
14
+ ESCL: '_uscan._tcp', // eSCL/AirScan scanners
15
+ ESCL_SECURE: '_uscans._tcp', // eSCL over TLS
16
+ SANE: '_scanner._tcp', // SANE network scanners
17
+
18
+ // Printers
19
+ IPP: '_ipp._tcp', // IPP printers
20
+ IPPS: '_ipps._tcp', // IPP over TLS
21
+ PDL: '_pdl-datastream._tcp', // Raw/JetDirect printers
22
+
23
+ // Speakers / Audio
24
+ AIRPLAY: '_airplay._tcp', // AirPlay devices (Apple TV, HomePod, etc.)
25
+ RAOP: '_raop._tcp', // Remote Audio Output Protocol (AirPlay audio)
26
+ SONOS: '_sonos._tcp', // Sonos speakers
27
+ GOOGLECAST: '_googlecast._tcp', // Chromecast / Google Cast devices
28
+ SPOTIFY: '_spotify-connect._tcp', // Spotify Connect devices
29
+ } as const;
30
+
31
+ /**
32
+ * Default discovery options
33
+ */
34
+ const DEFAULT_OPTIONS: Required<IDiscoveryOptions> = {
35
+ serviceTypes: [
36
+ // Scanners
37
+ SERVICE_TYPES.ESCL,
38
+ SERVICE_TYPES.ESCL_SECURE,
39
+ SERVICE_TYPES.SANE,
40
+ // Printers
41
+ SERVICE_TYPES.IPP,
42
+ SERVICE_TYPES.IPPS,
43
+ // Speakers
44
+ SERVICE_TYPES.AIRPLAY,
45
+ SERVICE_TYPES.RAOP,
46
+ SERVICE_TYPES.SONOS,
47
+ SERVICE_TYPES.GOOGLECAST,
48
+ ],
49
+ timeout: 10000,
50
+ };
51
+
52
+ /**
53
+ * mDNS/Bonjour discovery service for network devices
54
+ */
55
+ export class MdnsDiscovery extends plugins.events.EventEmitter {
56
+ private bonjour: plugins.bonjourService.Bonjour | null = null;
57
+ private browsers: plugins.bonjourService.Browser[] = [];
58
+ private discoveredDevices: Map<string, IDiscoveredDevice> = new Map();
59
+ private options: Required<IDiscoveryOptions>;
60
+ private isRunning: boolean = false;
61
+
62
+ constructor(options?: IDiscoveryOptions) {
63
+ super();
64
+ this.options = { ...DEFAULT_OPTIONS, ...options };
65
+ }
66
+
67
+ /**
68
+ * Check if discovery is currently running
69
+ */
70
+ public get running(): boolean {
71
+ return this.isRunning;
72
+ }
73
+
74
+ /**
75
+ * Get all discovered devices
76
+ */
77
+ public getDevices(): IDiscoveredDevice[] {
78
+ return Array.from(this.discoveredDevices.values());
79
+ }
80
+
81
+ /**
82
+ * Get discovered scanners only
83
+ */
84
+ public getScanners(): IDiscoveredDevice[] {
85
+ return this.getDevices().filter((d) => d.type === 'scanner');
86
+ }
87
+
88
+ /**
89
+ * Get discovered printers only
90
+ */
91
+ public getPrinters(): IDiscoveredDevice[] {
92
+ return this.getDevices().filter((d) => d.type === 'printer');
93
+ }
94
+
95
+ /**
96
+ * Start mDNS discovery
97
+ */
98
+ public async start(): Promise<void> {
99
+ if (this.isRunning) {
100
+ return;
101
+ }
102
+
103
+ this.bonjour = new plugins.bonjourService.Bonjour();
104
+ this.isRunning = true;
105
+ this.emit('started');
106
+
107
+ for (const serviceType of this.options.serviceTypes) {
108
+ this.browseService(serviceType);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Stop mDNS discovery
114
+ */
115
+ public async stop(): Promise<void> {
116
+ if (!this.isRunning) {
117
+ return;
118
+ }
119
+
120
+ // Stop all browsers
121
+ for (const browser of this.browsers) {
122
+ browser.stop();
123
+ }
124
+ this.browsers = [];
125
+
126
+ // Destroy bonjour instance
127
+ if (this.bonjour) {
128
+ this.bonjour.destroy();
129
+ this.bonjour = null;
130
+ }
131
+
132
+ this.isRunning = false;
133
+ this.emit('stopped');
134
+ }
135
+
136
+ /**
137
+ * Clear discovered devices
138
+ */
139
+ public clear(): void {
140
+ this.discoveredDevices.clear();
141
+ }
142
+
143
+ /**
144
+ * Browse for a specific service type
145
+ */
146
+ private browseService(serviceType: string): void {
147
+ if (!this.bonjour) {
148
+ return;
149
+ }
150
+
151
+ const browser = this.bonjour.find({ type: serviceType }, (service) => {
152
+ this.handleServiceFound(service, serviceType);
153
+ });
154
+
155
+ browser.on('down', (service) => {
156
+ this.handleServiceLost(service, serviceType);
157
+ });
158
+
159
+ this.browsers.push(browser);
160
+ }
161
+
162
+ /**
163
+ * Handle discovered service
164
+ */
165
+ private handleServiceFound(
166
+ service: plugins.bonjourService.Service,
167
+ serviceType: string
168
+ ): void {
169
+ const device = this.parseService(service, serviceType);
170
+ if (!device) {
171
+ return;
172
+ }
173
+
174
+ const existingDevice = this.discoveredDevices.get(device.id);
175
+ if (existingDevice) {
176
+ // Update existing device
177
+ this.discoveredDevices.set(device.id, device);
178
+ this.emit('device:updated', device);
179
+ } else {
180
+ // New device found
181
+ this.discoveredDevices.set(device.id, device);
182
+ this.emit('device:found', device);
183
+
184
+ if (device.type === 'scanner') {
185
+ this.emit('scanner:found', device);
186
+ } else if (device.type === 'printer') {
187
+ this.emit('printer:found', device);
188
+ }
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Handle lost service
194
+ */
195
+ private handleServiceLost(
196
+ service: plugins.bonjourService.Service,
197
+ serviceType: string
198
+ ): void {
199
+ const deviceId = this.generateDeviceId(service, serviceType);
200
+ const device = this.discoveredDevices.get(deviceId);
201
+
202
+ if (device) {
203
+ this.discoveredDevices.delete(deviceId);
204
+ this.emit('device:lost', device);
205
+
206
+ if (device.type === 'scanner') {
207
+ this.emit('scanner:lost', deviceId);
208
+ } else if (device.type === 'printer') {
209
+ this.emit('printer:lost', deviceId);
210
+ }
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Parse Bonjour service into device info
216
+ */
217
+ private parseService(
218
+ service: plugins.bonjourService.Service,
219
+ serviceType: string
220
+ ): IDiscoveredDevice | null {
221
+ const addresses = service.addresses ?? [];
222
+ // Prefer IPv4 address
223
+ const address =
224
+ addresses.find((a) => !a.includes(':')) ?? addresses[0] ?? service.host;
225
+
226
+ if (!address) {
227
+ return null;
228
+ }
229
+
230
+ const txtRecords = this.parseTxtRecords(service.txt);
231
+ const deviceType = this.getDeviceType(serviceType);
232
+ const protocol = this.getProtocol(serviceType);
233
+
234
+ const deviceId = this.generateDeviceId(service, serviceType);
235
+
236
+ return {
237
+ id: deviceId,
238
+ name: service.name || txtRecords['ty'] || txtRecords['product'] || 'Unknown Device',
239
+ type: deviceType,
240
+ protocol: protocol,
241
+ address: address,
242
+ port: service.port,
243
+ txtRecords: txtRecords,
244
+ serviceType: serviceType,
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Generate unique device ID
250
+ */
251
+ private generateDeviceId(
252
+ service: plugins.bonjourService.Service,
253
+ serviceType: string
254
+ ): string {
255
+ const addresses = service.addresses ?? [];
256
+ const address = addresses.find((a) => !a.includes(':')) ?? addresses[0] ?? service.host;
257
+ return `${serviceType}:${address}:${service.port}`;
258
+ }
259
+
260
+ /**
261
+ * Parse TXT records from service
262
+ */
263
+ private parseTxtRecords(
264
+ txt: Record<string, unknown> | undefined
265
+ ): Record<string, string> {
266
+ const records: Record<string, string> = {};
267
+
268
+ if (!txt) {
269
+ return records;
270
+ }
271
+
272
+ for (const [key, value] of Object.entries(txt)) {
273
+ if (typeof value === 'string') {
274
+ records[key] = value;
275
+ } else if (Buffer.isBuffer(value)) {
276
+ records[key] = value.toString('utf-8');
277
+ } else if (value !== undefined && value !== null) {
278
+ records[key] = String(value);
279
+ }
280
+ }
281
+
282
+ return records;
283
+ }
284
+
285
+ /**
286
+ * Determine device type from service type
287
+ */
288
+ private getDeviceType(serviceType: string): TDeviceType {
289
+ switch (serviceType) {
290
+ case SERVICE_TYPES.ESCL:
291
+ case SERVICE_TYPES.ESCL_SECURE:
292
+ case SERVICE_TYPES.SANE:
293
+ return 'scanner';
294
+ case SERVICE_TYPES.IPP:
295
+ case SERVICE_TYPES.IPPS:
296
+ case SERVICE_TYPES.PDL:
297
+ return 'printer';
298
+ case SERVICE_TYPES.AIRPLAY:
299
+ case SERVICE_TYPES.RAOP:
300
+ case SERVICE_TYPES.SONOS:
301
+ case SERVICE_TYPES.GOOGLECAST:
302
+ case SERVICE_TYPES.SPOTIFY:
303
+ return 'speaker';
304
+ default:
305
+ // Check if it's a scanner or printer based on service type pattern
306
+ if (serviceType.includes('scan') || serviceType.includes('scanner')) {
307
+ return 'scanner';
308
+ }
309
+ if (serviceType.includes('airplay') || serviceType.includes('raop') ||
310
+ serviceType.includes('sonos') || serviceType.includes('cast') ||
311
+ serviceType.includes('spotify')) {
312
+ return 'speaker';
313
+ }
314
+ return 'printer';
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Determine protocol from service type
320
+ */
321
+ private getProtocol(serviceType: string): string {
322
+ switch (serviceType) {
323
+ case SERVICE_TYPES.ESCL:
324
+ case SERVICE_TYPES.ESCL_SECURE:
325
+ return 'escl';
326
+ case SERVICE_TYPES.SANE:
327
+ return 'sane';
328
+ case SERVICE_TYPES.IPP:
329
+ case SERVICE_TYPES.IPPS:
330
+ case SERVICE_TYPES.PDL:
331
+ return 'ipp';
332
+ case SERVICE_TYPES.AIRPLAY:
333
+ case SERVICE_TYPES.RAOP:
334
+ return 'airplay';
335
+ case SERVICE_TYPES.SONOS:
336
+ return 'sonos';
337
+ case SERVICE_TYPES.GOOGLECAST:
338
+ return 'chromecast';
339
+ case SERVICE_TYPES.SPOTIFY:
340
+ return 'spotify';
341
+ default:
342
+ return 'unknown';
343
+ }
344
+ }
345
+ }
346
+
347
+ export { SERVICE_TYPES };