@ecobridge.xyz/devicemanager 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitea/workflows/default_nottags.yaml +66 -0
- package/.gitea/workflows/default_tags.yaml +124 -0
- package/.vscode/launch.json +11 -0
- package/.vscode/settings.json +26 -0
- package/changelog.md +91 -0
- package/dist_ts/00_commitinfo_data.d.ts +8 -0
- package/dist_ts/00_commitinfo_data.js +9 -0
- package/dist_ts/device/device.classes.device.d.ts +205 -0
- package/dist_ts/device/device.classes.device.js +344 -0
- package/dist_ts/devicemanager.classes.devicemanager.d.ts +210 -0
- package/dist_ts/devicemanager.classes.devicemanager.js +835 -0
- package/dist_ts/discovery/discovery.classes.homeassistant.d.ts +101 -0
- package/dist_ts/discovery/discovery.classes.homeassistant.js +313 -0
- package/dist_ts/discovery/discovery.classes.mdns.d.ts +90 -0
- package/dist_ts/discovery/discovery.classes.mdns.js +291 -0
- package/dist_ts/discovery/discovery.classes.networkscanner.d.ts +64 -0
- package/dist_ts/discovery/discovery.classes.networkscanner.js +535 -0
- package/dist_ts/discovery/discovery.classes.ssdp.d.ts +112 -0
- package/dist_ts/discovery/discovery.classes.ssdp.js +255 -0
- package/dist_ts/factories/index.d.ts +190 -0
- package/dist_ts/factories/index.js +350 -0
- package/dist_ts/features/feature.abstract.d.ts +118 -0
- package/dist_ts/features/feature.abstract.js +172 -0
- package/dist_ts/features/feature.camera.d.ts +95 -0
- package/dist_ts/features/feature.camera.js +162 -0
- package/dist_ts/features/feature.climate.d.ts +174 -0
- package/dist_ts/features/feature.climate.js +328 -0
- package/dist_ts/features/feature.cover.d.ts +121 -0
- package/dist_ts/features/feature.cover.js +217 -0
- package/dist_ts/features/feature.fan.d.ts +127 -0
- package/dist_ts/features/feature.fan.js +235 -0
- package/dist_ts/features/feature.light.d.ts +156 -0
- package/dist_ts/features/feature.light.js +296 -0
- package/dist_ts/features/feature.lock.d.ts +103 -0
- package/dist_ts/features/feature.lock.js +170 -0
- package/dist_ts/features/feature.playback.d.ts +98 -0
- package/dist_ts/features/feature.playback.js +191 -0
- package/dist_ts/features/feature.power.d.ts +79 -0
- package/dist_ts/features/feature.power.js +173 -0
- package/dist_ts/features/feature.print.d.ts +78 -0
- package/dist_ts/features/feature.print.js +226 -0
- package/dist_ts/features/feature.scan.d.ts +79 -0
- package/dist_ts/features/feature.scan.js +315 -0
- package/dist_ts/features/feature.sensor.d.ts +95 -0
- package/dist_ts/features/feature.sensor.js +147 -0
- package/dist_ts/features/feature.snmp.d.ts +88 -0
- package/dist_ts/features/feature.snmp.js +175 -0
- package/dist_ts/features/feature.switch.d.ts +75 -0
- package/dist_ts/features/feature.switch.js +126 -0
- package/dist_ts/features/feature.volume.d.ts +104 -0
- package/dist_ts/features/feature.volume.js +186 -0
- package/dist_ts/features/index.d.ts +19 -0
- package/dist_ts/features/index.js +23 -0
- package/dist_ts/helpers/helpers.iprange.d.ts +35 -0
- package/dist_ts/helpers/helpers.iprange.js +151 -0
- package/dist_ts/helpers/helpers.retry.d.ts +25 -0
- package/dist_ts/helpers/helpers.retry.js +69 -0
- package/dist_ts/index.d.ts +15 -0
- package/dist_ts/index.js +40 -0
- package/dist_ts/interfaces/feature.interfaces.d.ts +255 -0
- package/dist_ts/interfaces/feature.interfaces.js +6 -0
- package/dist_ts/interfaces/homeassistant.interfaces.d.ts +550 -0
- package/dist_ts/interfaces/homeassistant.interfaces.js +111 -0
- package/dist_ts/interfaces/index.d.ts +330 -0
- package/dist_ts/interfaces/index.js +16 -0
- package/dist_ts/interfaces/smarthome.interfaces.d.ts +282 -0
- package/dist_ts/interfaces/smarthome.interfaces.js +7 -0
- package/dist_ts/paths.d.ts +1 -0
- package/dist_ts/paths.js +3 -0
- package/dist_ts/plugins.d.ts +23 -0
- package/dist_ts/plugins.js +30 -0
- package/dist_ts/protocols/index.d.ts +12 -0
- package/dist_ts/protocols/index.js +21 -0
- package/dist_ts/protocols/protocol.escl.d.ts +58 -0
- package/dist_ts/protocols/protocol.escl.js +364 -0
- package/dist_ts/protocols/protocol.homeassistant.d.ts +248 -0
- package/dist_ts/protocols/protocol.homeassistant.js +611 -0
- package/dist_ts/protocols/protocol.ipp.d.ts +45 -0
- package/dist_ts/protocols/protocol.ipp.js +284 -0
- package/dist_ts/protocols/protocol.nut.d.ts +160 -0
- package/dist_ts/protocols/protocol.nut.js +364 -0
- package/dist_ts/protocols/protocol.sane.d.ts +104 -0
- package/dist_ts/protocols/protocol.sane.js +597 -0
- package/dist_ts/protocols/protocol.snmp.d.ts +133 -0
- package/dist_ts/protocols/protocol.snmp.js +333 -0
- package/dist_ts/protocols/protocol.upnp.d.ts +207 -0
- package/dist_ts/protocols/protocol.upnp.js +442 -0
- package/dist_ts/protocols/protocol.upssnmp.d.ts +160 -0
- package/dist_ts/protocols/protocol.upssnmp.js +263 -0
- package/npmextra.json +24 -0
- package/package.json +39 -0
- package/readme.hints.md +115 -0
- package/readme.md +624 -0
- package/test/test.ts +261 -0
- package/ts/00_commitinfo_data.ts +8 -0
- package/ts/device/device.classes.device.ts +454 -0
- package/ts/devicemanager.classes.devicemanager.ts +1219 -0
- package/ts/discovery/discovery.classes.homeassistant.ts +376 -0
- package/ts/discovery/discovery.classes.mdns.ts +347 -0
- package/ts/discovery/discovery.classes.networkscanner.ts +648 -0
- package/ts/discovery/discovery.classes.ssdp.ts +357 -0
- package/ts/factories/index.ts +696 -0
- package/ts/features/feature.abstract.ts +251 -0
- package/ts/features/feature.camera.ts +214 -0
- package/ts/features/feature.climate.ts +407 -0
- package/ts/features/feature.cover.ts +278 -0
- package/ts/features/feature.fan.ts +296 -0
- package/ts/features/feature.light.ts +369 -0
- package/ts/features/feature.lock.ts +223 -0
- package/ts/features/feature.playback.ts +246 -0
- package/ts/features/feature.power.ts +231 -0
- package/ts/features/feature.print.ts +297 -0
- package/ts/features/feature.scan.ts +370 -0
- package/ts/features/feature.sensor.ts +202 -0
- package/ts/features/feature.snmp.ts +235 -0
- package/ts/features/feature.switch.ts +170 -0
- package/ts/features/feature.volume.ts +256 -0
- package/ts/features/index.ts +25 -0
- package/ts/helpers/helpers.iprange.ts +172 -0
- package/ts/helpers/helpers.retry.ts +100 -0
- package/ts/index.ts +165 -0
- package/ts/interfaces/feature.interfaces.ts +359 -0
- package/ts/interfaces/homeassistant.interfaces.ts +666 -0
- package/ts/interfaces/index.ts +421 -0
- package/ts/interfaces/smarthome.interfaces.ts +421 -0
- package/ts/paths.ts +5 -0
- package/ts/plugins.ts +41 -0
- package/ts/protocols/index.ts +59 -0
- package/ts/protocols/protocol.escl.ts +439 -0
- package/ts/protocols/protocol.homeassistant.ts +737 -0
- package/ts/protocols/protocol.ipp.ts +329 -0
- package/ts/protocols/protocol.nut.ts +471 -0
- package/ts/protocols/protocol.sane.ts +694 -0
- package/ts/protocols/protocol.snmp.ts +439 -0
- package/ts/protocols/protocol.upnp.ts +627 -0
- package/ts/protocols/protocol.upssnmp.ts +377 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,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 };
|