@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,737 @@
1
+ import * as plugins from '../plugins.js';
2
+ import type {
3
+ IHomeAssistantInstanceConfig,
4
+ IHomeAssistantEntity,
5
+ IHomeAssistantConfig,
6
+ IHomeAssistantStateChangedEvent,
7
+ IHomeAssistantMessage,
8
+ IHomeAssistantAuthRequired,
9
+ IHomeAssistantAuthOk,
10
+ IHomeAssistantAuthInvalid,
11
+ IHomeAssistantResult,
12
+ IHomeAssistantEvent,
13
+ THomeAssistantProtocolEvents,
14
+ IHomeAssistantLightServiceData,
15
+ IHomeAssistantClimateServiceData,
16
+ IHomeAssistantCoverServiceData,
17
+ IHomeAssistantFanServiceData,
18
+ IHomeAssistantMediaPlayerServiceData,
19
+ } from '../interfaces/homeassistant.interfaces.js';
20
+
21
+ /**
22
+ * Home Assistant WebSocket Protocol Handler
23
+ * Connects to HA via WebSocket, handles authentication, state subscriptions, and service calls
24
+ */
25
+ export class HomeAssistantProtocol extends plugins.events.EventEmitter {
26
+ private ws: InstanceType<typeof plugins.WebSocket> | null = null;
27
+ private config: IHomeAssistantInstanceConfig;
28
+ private messageId: number = 1;
29
+ private pendingRequests: Map<number, {
30
+ resolve: (value: unknown) => void;
31
+ reject: (error: Error) => void;
32
+ timeout: ReturnType<typeof setTimeout>;
33
+ }> = new Map();
34
+ private isAuthenticated: boolean = false;
35
+ private haConfig: IHomeAssistantConfig | null = null;
36
+ private reconnectAttempt: number = 0;
37
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
38
+ private stateSubscriptionId: number | null = null;
39
+ private entityStates: Map<string, IHomeAssistantEntity> = new Map();
40
+ private intentionalDisconnect: boolean = false;
41
+
42
+ constructor(config: IHomeAssistantInstanceConfig) {
43
+ super();
44
+ this.config = {
45
+ port: 8123,
46
+ secure: false,
47
+ autoReconnect: true,
48
+ reconnectDelay: 5000,
49
+ ...config,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Get the WebSocket URL for this HA instance
55
+ */
56
+ private get wsUrl(): string {
57
+ const protocol = this.config.secure ? 'wss' : 'ws';
58
+ return `${protocol}://${this.config.host}:${this.config.port}/api/websocket`;
59
+ }
60
+
61
+ /**
62
+ * Get connection state
63
+ */
64
+ public get isConnected(): boolean {
65
+ return this.ws !== null && this.ws.readyState === plugins.WebSocket.OPEN && this.isAuthenticated;
66
+ }
67
+
68
+ /**
69
+ * Get HA config if authenticated
70
+ */
71
+ public get homeAssistantConfig(): IHomeAssistantConfig | null {
72
+ return this.haConfig;
73
+ }
74
+
75
+ /**
76
+ * Get all cached entity states
77
+ */
78
+ public get entities(): Map<string, IHomeAssistantEntity> {
79
+ return this.entityStates;
80
+ }
81
+
82
+ /**
83
+ * Connect to Home Assistant
84
+ */
85
+ public async connect(): Promise<void> {
86
+ if (this.ws && this.ws.readyState === plugins.WebSocket.OPEN) {
87
+ return;
88
+ }
89
+
90
+ this.intentionalDisconnect = false;
91
+
92
+ return new Promise((resolve, reject) => {
93
+ try {
94
+ this.ws = new plugins.WebSocket(this.wsUrl);
95
+
96
+ const connectionTimeout = setTimeout(() => {
97
+ if (this.ws) {
98
+ this.ws.close();
99
+ this.ws = null;
100
+ }
101
+ reject(new Error(`Connection timeout to ${this.wsUrl}`));
102
+ }, 10000);
103
+
104
+ this.ws.on('open', () => {
105
+ // Connection established, waiting for auth_required
106
+ });
107
+
108
+ this.ws.on('message', async (data: Buffer | string) => {
109
+ const message = JSON.parse(data.toString()) as IHomeAssistantMessage;
110
+
111
+ if (message.type === 'auth_required') {
112
+ // Send authentication
113
+ await this.sendAuth();
114
+ } else if (message.type === 'auth_ok') {
115
+ clearTimeout(connectionTimeout);
116
+ this.isAuthenticated = true;
117
+ this.reconnectAttempt = 0;
118
+
119
+ // Get HA config
120
+ try {
121
+ this.haConfig = await this.getConfig();
122
+ this.emit('authenticated', this.haConfig);
123
+ } catch (err) {
124
+ // Non-fatal, continue anyway
125
+ }
126
+
127
+ this.emit('connected');
128
+ resolve();
129
+ } else if (message.type === 'auth_invalid') {
130
+ clearTimeout(connectionTimeout);
131
+ const authInvalid = message as IHomeAssistantAuthInvalid;
132
+ this.emit('auth:failed', authInvalid.message);
133
+ reject(new Error(`Authentication failed: ${authInvalid.message}`));
134
+ } else {
135
+ // Handle other messages
136
+ this.handleMessage(message);
137
+ }
138
+ });
139
+
140
+ this.ws.on('error', (error: Error) => {
141
+ this.emit('error', error);
142
+ });
143
+
144
+ this.ws.on('close', () => {
145
+ this.isAuthenticated = false;
146
+ this.stateSubscriptionId = null;
147
+
148
+ // Reject all pending requests
149
+ for (const [id, request] of this.pendingRequests) {
150
+ clearTimeout(request.timeout);
151
+ request.reject(new Error('Connection closed'));
152
+ this.pendingRequests.delete(id);
153
+ }
154
+
155
+ this.emit('disconnected');
156
+
157
+ // Auto-reconnect if not intentional
158
+ if (this.config.autoReconnect && !this.intentionalDisconnect) {
159
+ this.scheduleReconnect();
160
+ }
161
+ });
162
+ } catch (err) {
163
+ reject(err);
164
+ }
165
+ });
166
+ }
167
+
168
+ /**
169
+ * Disconnect from Home Assistant
170
+ */
171
+ public async disconnect(): Promise<void> {
172
+ this.intentionalDisconnect = true;
173
+
174
+ if (this.reconnectTimer) {
175
+ clearTimeout(this.reconnectTimer);
176
+ this.reconnectTimer = null;
177
+ }
178
+
179
+ if (this.ws) {
180
+ // Clear all pending requests
181
+ for (const [id, request] of this.pendingRequests) {
182
+ clearTimeout(request.timeout);
183
+ request.reject(new Error('Disconnecting'));
184
+ this.pendingRequests.delete(id);
185
+ }
186
+
187
+ this.ws.close();
188
+ this.ws = null;
189
+ }
190
+
191
+ this.isAuthenticated = false;
192
+ this.stateSubscriptionId = null;
193
+ this.entityStates.clear();
194
+ }
195
+
196
+ /**
197
+ * Send authentication message
198
+ */
199
+ private async sendAuth(): Promise<void> {
200
+ if (!this.ws) return;
201
+
202
+ const authMessage = {
203
+ type: 'auth',
204
+ access_token: this.config.token,
205
+ };
206
+
207
+ this.ws.send(JSON.stringify(authMessage));
208
+ }
209
+
210
+ /**
211
+ * Handle incoming messages
212
+ */
213
+ private handleMessage(message: IHomeAssistantMessage): void {
214
+ if (message.type === 'result') {
215
+ const result = message as IHomeAssistantResult;
216
+ const pending = this.pendingRequests.get(result.id);
217
+
218
+ if (pending) {
219
+ clearTimeout(pending.timeout);
220
+ this.pendingRequests.delete(result.id);
221
+
222
+ if (result.success) {
223
+ pending.resolve(result.result);
224
+ } else {
225
+ pending.reject(new Error(result.error?.message || 'Unknown error'));
226
+ }
227
+ }
228
+ } else if (message.type === 'event') {
229
+ const event = message as IHomeAssistantEvent;
230
+
231
+ if (event.event.event_type === 'state_changed') {
232
+ const stateChanged = event.event.data as IHomeAssistantStateChangedEvent;
233
+
234
+ // Update cached state
235
+ if (stateChanged.new_state) {
236
+ this.entityStates.set(stateChanged.entity_id, stateChanged.new_state);
237
+ } else {
238
+ this.entityStates.delete(stateChanged.entity_id);
239
+ }
240
+
241
+ this.emit('state:changed', stateChanged);
242
+ }
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Send a request and wait for response
248
+ */
249
+ private async sendRequest<T>(type: string, data: Record<string, unknown> = {}): Promise<T> {
250
+ if (!this.ws || !this.isAuthenticated) {
251
+ throw new Error('Not connected to Home Assistant');
252
+ }
253
+
254
+ const id = this.messageId++;
255
+ const message = { id, type, ...data };
256
+
257
+ return new Promise<T>((resolve, reject) => {
258
+ const timeout = setTimeout(() => {
259
+ this.pendingRequests.delete(id);
260
+ reject(new Error(`Request timeout: ${type}`));
261
+ }, 30000);
262
+
263
+ this.pendingRequests.set(id, {
264
+ resolve: resolve as (value: unknown) => void,
265
+ reject,
266
+ timeout,
267
+ });
268
+
269
+ this.ws!.send(JSON.stringify(message));
270
+ });
271
+ }
272
+
273
+ /**
274
+ * Schedule reconnection attempt
275
+ */
276
+ private scheduleReconnect(): void {
277
+ if (this.reconnectTimer) return;
278
+
279
+ this.reconnectAttempt++;
280
+ const delay = Math.min(
281
+ this.config.reconnectDelay! * Math.pow(1.5, this.reconnectAttempt - 1),
282
+ 60000 // Max 60 seconds
283
+ );
284
+
285
+ this.emit('reconnecting', this.reconnectAttempt);
286
+
287
+ this.reconnectTimer = setTimeout(async () => {
288
+ this.reconnectTimer = null;
289
+
290
+ try {
291
+ await this.connect();
292
+
293
+ // Re-subscribe to state changes
294
+ if (this.isConnected) {
295
+ await this.subscribeToStateChanges();
296
+ }
297
+ } catch {
298
+ // connect() will schedule another reconnect on failure
299
+ }
300
+ }, delay);
301
+ }
302
+
303
+ // ==========================================================================
304
+ // Public API - State
305
+ // ==========================================================================
306
+
307
+ /**
308
+ * Get HA config
309
+ */
310
+ public async getConfig(): Promise<IHomeAssistantConfig> {
311
+ return this.sendRequest<IHomeAssistantConfig>('get_config');
312
+ }
313
+
314
+ /**
315
+ * Subscribe to state change events
316
+ */
317
+ public async subscribeToStateChanges(): Promise<number> {
318
+ const result = await this.sendRequest<{ context: { id: string } }>('subscribe_events', {
319
+ event_type: 'state_changed',
320
+ });
321
+
322
+ // Get all current states after subscribing
323
+ const states = await this.getStates();
324
+ for (const entity of states) {
325
+ this.entityStates.set(entity.entity_id, entity);
326
+ }
327
+ this.emit('states:loaded', states);
328
+
329
+ this.stateSubscriptionId = this.messageId - 1;
330
+ return this.stateSubscriptionId;
331
+ }
332
+
333
+ /**
334
+ * Get all entity states
335
+ */
336
+ public async getStates(): Promise<IHomeAssistantEntity[]> {
337
+ return this.sendRequest<IHomeAssistantEntity[]>('get_states');
338
+ }
339
+
340
+ /**
341
+ * Get a specific entity state
342
+ */
343
+ public async getState(entityId: string): Promise<IHomeAssistantEntity | null> {
344
+ // First check cache
345
+ const cached = this.entityStates.get(entityId);
346
+ if (cached) return cached;
347
+
348
+ // Otherwise fetch all states and find it
349
+ const states = await this.getStates();
350
+ return states.find((s) => s.entity_id === entityId) || null;
351
+ }
352
+
353
+ /**
354
+ * Get entities by domain
355
+ */
356
+ public async getEntitiesByDomain(domain: string): Promise<IHomeAssistantEntity[]> {
357
+ const states = await this.getStates();
358
+ return states.filter((s) => s.entity_id.startsWith(`${domain}.`));
359
+ }
360
+
361
+ // ==========================================================================
362
+ // Public API - Service Calls
363
+ // ==========================================================================
364
+
365
+ /**
366
+ * Call a Home Assistant service
367
+ */
368
+ public async callService(
369
+ domain: string,
370
+ service: string,
371
+ target?: { entity_id?: string | string[]; device_id?: string | string[]; area_id?: string | string[] },
372
+ serviceData?: Record<string, unknown>
373
+ ): Promise<void> {
374
+ await this.sendRequest('call_service', {
375
+ domain,
376
+ service,
377
+ target,
378
+ service_data: serviceData,
379
+ });
380
+ }
381
+
382
+ /**
383
+ * Turn on an entity
384
+ */
385
+ public async turnOn(entityId: string, data?: Record<string, unknown>): Promise<void> {
386
+ const domain = entityId.split('.')[0];
387
+ await this.callService(domain, 'turn_on', { entity_id: entityId }, data);
388
+ }
389
+
390
+ /**
391
+ * Turn off an entity
392
+ */
393
+ public async turnOff(entityId: string): Promise<void> {
394
+ const domain = entityId.split('.')[0];
395
+ await this.callService(domain, 'turn_off', { entity_id: entityId });
396
+ }
397
+
398
+ /**
399
+ * Toggle an entity
400
+ */
401
+ public async toggle(entityId: string): Promise<void> {
402
+ const domain = entityId.split('.')[0];
403
+ await this.callService(domain, 'toggle', { entity_id: entityId });
404
+ }
405
+
406
+ // ==========================================================================
407
+ // Light Services
408
+ // ==========================================================================
409
+
410
+ /**
411
+ * Control a light
412
+ */
413
+ public async lightTurnOn(entityId: string, options?: IHomeAssistantLightServiceData): Promise<void> {
414
+ await this.callService('light', 'turn_on', { entity_id: entityId }, options);
415
+ }
416
+
417
+ public async lightTurnOff(entityId: string, options?: { transition?: number }): Promise<void> {
418
+ await this.callService('light', 'turn_off', { entity_id: entityId }, options);
419
+ }
420
+
421
+ public async lightToggle(entityId: string, options?: IHomeAssistantLightServiceData): Promise<void> {
422
+ await this.callService('light', 'toggle', { entity_id: entityId }, options);
423
+ }
424
+
425
+ // ==========================================================================
426
+ // Climate Services
427
+ // ==========================================================================
428
+
429
+ /**
430
+ * Set HVAC mode
431
+ */
432
+ public async climateSetHvacMode(entityId: string, hvacMode: string): Promise<void> {
433
+ await this.callService('climate', 'set_hvac_mode', { entity_id: entityId }, { hvac_mode: hvacMode });
434
+ }
435
+
436
+ /**
437
+ * Set target temperature
438
+ */
439
+ public async climateSetTemperature(entityId: string, options: IHomeAssistantClimateServiceData): Promise<void> {
440
+ await this.callService('climate', 'set_temperature', { entity_id: entityId }, options);
441
+ }
442
+
443
+ /**
444
+ * Set fan mode
445
+ */
446
+ public async climateSetFanMode(entityId: string, fanMode: string): Promise<void> {
447
+ await this.callService('climate', 'set_fan_mode', { entity_id: entityId }, { fan_mode: fanMode });
448
+ }
449
+
450
+ /**
451
+ * Set preset mode
452
+ */
453
+ public async climateSetPresetMode(entityId: string, presetMode: string): Promise<void> {
454
+ await this.callService('climate', 'set_preset_mode', { entity_id: entityId }, { preset_mode: presetMode });
455
+ }
456
+
457
+ /**
458
+ * Set swing mode
459
+ */
460
+ public async climateSetSwingMode(entityId: string, swingMode: string): Promise<void> {
461
+ await this.callService('climate', 'set_swing_mode', { entity_id: entityId }, { swing_mode: swingMode });
462
+ }
463
+
464
+ /**
465
+ * Set aux heat
466
+ */
467
+ public async climateSetAuxHeat(entityId: string, auxHeat: boolean): Promise<void> {
468
+ await this.callService('climate', 'set_aux_heat', { entity_id: entityId }, { aux_heat: auxHeat });
469
+ }
470
+
471
+ // ==========================================================================
472
+ // Cover Services
473
+ // ==========================================================================
474
+
475
+ /**
476
+ * Open cover
477
+ */
478
+ public async coverOpen(entityId: string): Promise<void> {
479
+ await this.callService('cover', 'open_cover', { entity_id: entityId });
480
+ }
481
+
482
+ /**
483
+ * Close cover
484
+ */
485
+ public async coverClose(entityId: string): Promise<void> {
486
+ await this.callService('cover', 'close_cover', { entity_id: entityId });
487
+ }
488
+
489
+ /**
490
+ * Stop cover
491
+ */
492
+ public async coverStop(entityId: string): Promise<void> {
493
+ await this.callService('cover', 'stop_cover', { entity_id: entityId });
494
+ }
495
+
496
+ /**
497
+ * Set cover position
498
+ */
499
+ public async coverSetPosition(entityId: string, position: number): Promise<void> {
500
+ await this.callService('cover', 'set_cover_position', { entity_id: entityId }, { position });
501
+ }
502
+
503
+ /**
504
+ * Set cover tilt position
505
+ */
506
+ public async coverSetTiltPosition(entityId: string, tiltPosition: number): Promise<void> {
507
+ await this.callService('cover', 'set_cover_tilt_position', { entity_id: entityId }, { tilt_position: tiltPosition });
508
+ }
509
+
510
+ // ==========================================================================
511
+ // Fan Services
512
+ // ==========================================================================
513
+
514
+ /**
515
+ * Turn on fan
516
+ */
517
+ public async fanTurnOn(entityId: string, options?: IHomeAssistantFanServiceData): Promise<void> {
518
+ await this.callService('fan', 'turn_on', { entity_id: entityId }, options);
519
+ }
520
+
521
+ /**
522
+ * Turn off fan
523
+ */
524
+ public async fanTurnOff(entityId: string): Promise<void> {
525
+ await this.callService('fan', 'turn_off', { entity_id: entityId });
526
+ }
527
+
528
+ /**
529
+ * Set fan percentage
530
+ */
531
+ public async fanSetPercentage(entityId: string, percentage: number): Promise<void> {
532
+ await this.callService('fan', 'set_percentage', { entity_id: entityId }, { percentage });
533
+ }
534
+
535
+ /**
536
+ * Set fan preset mode
537
+ */
538
+ public async fanSetPresetMode(entityId: string, presetMode: string): Promise<void> {
539
+ await this.callService('fan', 'set_preset_mode', { entity_id: entityId }, { preset_mode: presetMode });
540
+ }
541
+
542
+ /**
543
+ * Oscillate fan
544
+ */
545
+ public async fanOscillate(entityId: string, oscillating: boolean): Promise<void> {
546
+ await this.callService('fan', 'oscillate', { entity_id: entityId }, { oscillating });
547
+ }
548
+
549
+ /**
550
+ * Set fan direction
551
+ */
552
+ public async fanSetDirection(entityId: string, direction: 'forward' | 'reverse'): Promise<void> {
553
+ await this.callService('fan', 'set_direction', { entity_id: entityId }, { direction });
554
+ }
555
+
556
+ // ==========================================================================
557
+ // Lock Services
558
+ // ==========================================================================
559
+
560
+ /**
561
+ * Lock
562
+ */
563
+ public async lockLock(entityId: string): Promise<void> {
564
+ await this.callService('lock', 'lock', { entity_id: entityId });
565
+ }
566
+
567
+ /**
568
+ * Unlock
569
+ */
570
+ public async lockUnlock(entityId: string): Promise<void> {
571
+ await this.callService('lock', 'unlock', { entity_id: entityId });
572
+ }
573
+
574
+ /**
575
+ * Open (if supported)
576
+ */
577
+ public async lockOpen(entityId: string): Promise<void> {
578
+ await this.callService('lock', 'open', { entity_id: entityId });
579
+ }
580
+
581
+ // ==========================================================================
582
+ // Switch Services
583
+ // ==========================================================================
584
+
585
+ /**
586
+ * Turn on switch
587
+ */
588
+ public async switchTurnOn(entityId: string): Promise<void> {
589
+ await this.callService('switch', 'turn_on', { entity_id: entityId });
590
+ }
591
+
592
+ /**
593
+ * Turn off switch
594
+ */
595
+ public async switchTurnOff(entityId: string): Promise<void> {
596
+ await this.callService('switch', 'turn_off', { entity_id: entityId });
597
+ }
598
+
599
+ /**
600
+ * Toggle switch
601
+ */
602
+ public async switchToggle(entityId: string): Promise<void> {
603
+ await this.callService('switch', 'toggle', { entity_id: entityId });
604
+ }
605
+
606
+ // ==========================================================================
607
+ // Media Player Services
608
+ // ==========================================================================
609
+
610
+ /**
611
+ * Play media
612
+ */
613
+ public async mediaPlayerPlay(entityId: string): Promise<void> {
614
+ await this.callService('media_player', 'media_play', { entity_id: entityId });
615
+ }
616
+
617
+ /**
618
+ * Pause media
619
+ */
620
+ public async mediaPlayerPause(entityId: string): Promise<void> {
621
+ await this.callService('media_player', 'media_pause', { entity_id: entityId });
622
+ }
623
+
624
+ /**
625
+ * Stop media
626
+ */
627
+ public async mediaPlayerStop(entityId: string): Promise<void> {
628
+ await this.callService('media_player', 'media_stop', { entity_id: entityId });
629
+ }
630
+
631
+ /**
632
+ * Next track
633
+ */
634
+ public async mediaPlayerNext(entityId: string): Promise<void> {
635
+ await this.callService('media_player', 'media_next_track', { entity_id: entityId });
636
+ }
637
+
638
+ /**
639
+ * Previous track
640
+ */
641
+ public async mediaPlayerPrevious(entityId: string): Promise<void> {
642
+ await this.callService('media_player', 'media_previous_track', { entity_id: entityId });
643
+ }
644
+
645
+ /**
646
+ * Set volume
647
+ */
648
+ public async mediaPlayerSetVolume(entityId: string, volumeLevel: number): Promise<void> {
649
+ await this.callService('media_player', 'volume_set', { entity_id: entityId }, { volume_level: volumeLevel });
650
+ }
651
+
652
+ /**
653
+ * Mute/unmute
654
+ */
655
+ public async mediaPlayerMute(entityId: string, isMuted: boolean): Promise<void> {
656
+ await this.callService('media_player', 'volume_mute', { entity_id: entityId }, { is_volume_muted: isMuted });
657
+ }
658
+
659
+ /**
660
+ * Seek to position
661
+ */
662
+ public async mediaPlayerSeek(entityId: string, position: number): Promise<void> {
663
+ await this.callService('media_player', 'media_seek', { entity_id: entityId }, { seek_position: position });
664
+ }
665
+
666
+ /**
667
+ * Select source
668
+ */
669
+ public async mediaPlayerSelectSource(entityId: string, source: string): Promise<void> {
670
+ await this.callService('media_player', 'select_source', { entity_id: entityId }, { source });
671
+ }
672
+
673
+ // ==========================================================================
674
+ // Camera Services
675
+ // ==========================================================================
676
+
677
+ /**
678
+ * Get camera snapshot URL
679
+ */
680
+ public getCameraSnapshotUrl(entityId: string): string {
681
+ const protocol = this.config.secure ? 'https' : 'http';
682
+ const entity = this.entityStates.get(entityId);
683
+ const accessToken = (entity?.attributes as { access_token?: string })?.access_token || '';
684
+ return `${protocol}://${this.config.host}:${this.config.port}/api/camera_proxy/${entityId}?token=${accessToken}`;
685
+ }
686
+
687
+ /**
688
+ * Get camera stream URL
689
+ */
690
+ public getCameraStreamUrl(entityId: string): string {
691
+ const protocol = this.config.secure ? 'https' : 'http';
692
+ return `${protocol}://${this.config.host}:${this.config.port}/api/camera_proxy_stream/${entityId}`;
693
+ }
694
+
695
+ // ==========================================================================
696
+ // Static Helpers
697
+ // ==========================================================================
698
+
699
+ /**
700
+ * Probe if a Home Assistant instance is reachable
701
+ */
702
+ public static async probe(host: string, port: number = 8123, secure: boolean = false, timeout: number = 5000): Promise<boolean> {
703
+ return new Promise((resolve) => {
704
+ const protocol = secure ? 'wss' : 'ws';
705
+ const url = `${protocol}://${host}:${port}/api/websocket`;
706
+
707
+ try {
708
+ const ws = new plugins.WebSocket(url);
709
+
710
+ const timer = setTimeout(() => {
711
+ ws.close();
712
+ resolve(false);
713
+ }, timeout);
714
+
715
+ ws.on('open', () => {
716
+ // Wait for auth_required message
717
+ });
718
+
719
+ ws.on('message', (data: Buffer | string) => {
720
+ const message = JSON.parse(data.toString()) as IHomeAssistantMessage;
721
+ if (message.type === 'auth_required') {
722
+ clearTimeout(timer);
723
+ ws.close();
724
+ resolve(true);
725
+ }
726
+ });
727
+
728
+ ws.on('error', () => {
729
+ clearTimeout(timer);
730
+ resolve(false);
731
+ });
732
+ } catch {
733
+ resolve(false);
734
+ }
735
+ });
736
+ }
737
+ }