@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,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
|
+
}
|