homebridge-ratgdo 2.8.1 → 2.9.1
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/dist/ratgdo-api.d.ts +313 -0
- package/dist/ratgdo-api.js +982 -0
- package/dist/ratgdo-api.js.map +1 -0
- package/dist/ratgdo-device.d.ts +30 -2
- package/dist/ratgdo-device.js +50 -82
- package/dist/ratgdo-device.js.map +1 -1
- package/dist/ratgdo-platform.d.ts +8 -2
- package/dist/ratgdo-platform.js +143 -101
- package/dist/ratgdo-platform.js.map +1 -1
- package/dist/settings.d.ts +1 -3
- package/dist/settings.js +3 -6
- package/dist/settings.js.map +1 -1
- package/package.json +7 -7
|
@@ -0,0 +1,982 @@
|
|
|
1
|
+
import { createConnection } from "node:net";
|
|
2
|
+
import { Buffer } from "node:buffer";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
// Define the minimum frame header size for message validation.
|
|
5
|
+
const MIN_FRAME_SIZE = 3;
|
|
6
|
+
// Define the fixed32 field size in bytes.
|
|
7
|
+
const FIXED32_SIZE = 4;
|
|
8
|
+
/**
|
|
9
|
+
* A subset of the ESPHome API message types that we need for Ratgdo.
|
|
10
|
+
*/
|
|
11
|
+
var MessageType;
|
|
12
|
+
(function (MessageType) {
|
|
13
|
+
MessageType[MessageType["HELLO_REQUEST"] = 1] = "HELLO_REQUEST";
|
|
14
|
+
MessageType[MessageType["HELLO_RESPONSE"] = 2] = "HELLO_RESPONSE";
|
|
15
|
+
MessageType[MessageType["CONNECT_REQUEST"] = 3] = "CONNECT_REQUEST";
|
|
16
|
+
MessageType[MessageType["CONNECT_RESPONSE"] = 4] = "CONNECT_RESPONSE";
|
|
17
|
+
MessageType[MessageType["DISCONNECT_REQUEST"] = 5] = "DISCONNECT_REQUEST";
|
|
18
|
+
MessageType[MessageType["DISCONNECT_RESPONSE"] = 6] = "DISCONNECT_RESPONSE";
|
|
19
|
+
MessageType[MessageType["PING_REQUEST"] = 7] = "PING_REQUEST";
|
|
20
|
+
MessageType[MessageType["PING_RESPONSE"] = 8] = "PING_RESPONSE";
|
|
21
|
+
MessageType[MessageType["DEVICE_INFO_REQUEST"] = 9] = "DEVICE_INFO_REQUEST";
|
|
22
|
+
MessageType[MessageType["DEVICE_INFO_RESPONSE"] = 10] = "DEVICE_INFO_RESPONSE";
|
|
23
|
+
MessageType[MessageType["LIST_ENTITIES_REQUEST"] = 11] = "LIST_ENTITIES_REQUEST";
|
|
24
|
+
MessageType[MessageType["LIST_ENTITIES_BINARY_SENSOR_RESPONSE"] = 12] = "LIST_ENTITIES_BINARY_SENSOR_RESPONSE";
|
|
25
|
+
MessageType[MessageType["LIST_ENTITIES_COVER_RESPONSE"] = 13] = "LIST_ENTITIES_COVER_RESPONSE";
|
|
26
|
+
MessageType[MessageType["LIST_ENTITIES_LIGHT_RESPONSE"] = 15] = "LIST_ENTITIES_LIGHT_RESPONSE";
|
|
27
|
+
MessageType[MessageType["LIST_ENTITIES_SENSOR_RESPONSE"] = 16] = "LIST_ENTITIES_SENSOR_RESPONSE";
|
|
28
|
+
MessageType[MessageType["LIST_ENTITIES_SWITCH_RESPONSE"] = 17] = "LIST_ENTITIES_SWITCH_RESPONSE";
|
|
29
|
+
MessageType[MessageType["LIST_ENTITIES_TEXT_SENSOR_RESPONSE"] = 18] = "LIST_ENTITIES_TEXT_SENSOR_RESPONSE";
|
|
30
|
+
MessageType[MessageType["LIST_ENTITIES_DONE_RESPONSE"] = 19] = "LIST_ENTITIES_DONE_RESPONSE";
|
|
31
|
+
MessageType[MessageType["SUBSCRIBE_STATES_REQUEST"] = 20] = "SUBSCRIBE_STATES_REQUEST";
|
|
32
|
+
MessageType[MessageType["BINARY_SENSOR_STATE"] = 21] = "BINARY_SENSOR_STATE";
|
|
33
|
+
MessageType[MessageType["COVER_STATE"] = 22] = "COVER_STATE";
|
|
34
|
+
MessageType[MessageType["LIGHT_STATE"] = 24] = "LIGHT_STATE";
|
|
35
|
+
MessageType[MessageType["SENSOR_STATE"] = 25] = "SENSOR_STATE";
|
|
36
|
+
MessageType[MessageType["SWITCH_STATE"] = 26] = "SWITCH_STATE";
|
|
37
|
+
MessageType[MessageType["TEXT_SENSOR_STATE"] = 27] = "TEXT_SENSOR_STATE";
|
|
38
|
+
MessageType[MessageType["COVER_COMMAND_REQUEST"] = 30] = "COVER_COMMAND_REQUEST";
|
|
39
|
+
MessageType[MessageType["FAN_COMMAND_REQUEST"] = 31] = "FAN_COMMAND_REQUEST";
|
|
40
|
+
MessageType[MessageType["LIGHT_COMMAND_REQUEST"] = 32] = "LIGHT_COMMAND_REQUEST";
|
|
41
|
+
MessageType[MessageType["SWITCH_COMMAND_REQUEST"] = 33] = "SWITCH_COMMAND_REQUEST";
|
|
42
|
+
MessageType[MessageType["GET_TIME_REQUEST"] = 36] = "GET_TIME_REQUEST";
|
|
43
|
+
MessageType[MessageType["GET_TIME_RESPONSE"] = 37] = "GET_TIME_RESPONSE";
|
|
44
|
+
MessageType[MessageType["LIST_ENTITIES_SERVICES_RESPONSE"] = 41] = "LIST_ENTITIES_SERVICES_RESPONSE";
|
|
45
|
+
MessageType[MessageType["LIST_ENTITIES_NUMBER_RESPONSE"] = 49] = "LIST_ENTITIES_NUMBER_RESPONSE";
|
|
46
|
+
MessageType[MessageType["NUMBER_STATE"] = 50] = "NUMBER_STATE";
|
|
47
|
+
MessageType[MessageType["LIST_ENTITIES_LOCK_RESPONSE"] = 58] = "LIST_ENTITIES_LOCK_RESPONSE";
|
|
48
|
+
MessageType[MessageType["LOCK_STATE"] = 59] = "LOCK_STATE";
|
|
49
|
+
MessageType[MessageType["LOCK_COMMAND_REQUEST"] = 60] = "LOCK_COMMAND_REQUEST";
|
|
50
|
+
MessageType[MessageType["LIST_ENTITIES_BUTTON_RESPONSE"] = 61] = "LIST_ENTITIES_BUTTON_RESPONSE";
|
|
51
|
+
MessageType[MessageType["BUTTON_COMMAND_REQUEST"] = 62] = "BUTTON_COMMAND_REQUEST";
|
|
52
|
+
})(MessageType || (MessageType = {}));
|
|
53
|
+
/**
|
|
54
|
+
* Wire types used in protobuf encoding.
|
|
55
|
+
*/
|
|
56
|
+
var WireType;
|
|
57
|
+
(function (WireType) {
|
|
58
|
+
WireType[WireType["VARINT"] = 0] = "VARINT";
|
|
59
|
+
WireType[WireType["FIXED64"] = 1] = "FIXED64";
|
|
60
|
+
WireType[WireType["LENGTH_DELIMITED"] = 2] = "LENGTH_DELIMITED";
|
|
61
|
+
WireType[WireType["FIXED32"] = 5] = "FIXED32";
|
|
62
|
+
})(WireType || (WireType = {}));
|
|
63
|
+
/**
|
|
64
|
+
* ESPHome API client for communicating with ESPHome devices.
|
|
65
|
+
* Implements the ESPHome native API protocol over TCP.
|
|
66
|
+
*
|
|
67
|
+
* @extends EventEmitter
|
|
68
|
+
* @emits connect - Connected to device.
|
|
69
|
+
* @emits disconnect - Disconnected from device.
|
|
70
|
+
* @emits message - Raw message received with type and payload.
|
|
71
|
+
* @emits entities - List of discovered entities after enumeration.
|
|
72
|
+
* @emits telemetry - Generic telemetry update for any entity.
|
|
73
|
+
* @emits heartbeat - Heartbeat response received.
|
|
74
|
+
* @emits {entityType} - Type-specific telemetry events (e.g., "cover", "light", "switch").
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```typescript
|
|
78
|
+
* const client = new EspHomeClient(ratgdo, "192.168.1.100");
|
|
79
|
+
* client.connect();
|
|
80
|
+
*
|
|
81
|
+
* // Listen for discovered entities
|
|
82
|
+
* client.on("entities", (entities) => {
|
|
83
|
+
*
|
|
84
|
+
* // Log all available entity IDs
|
|
85
|
+
* client.logAllEntityIds();
|
|
86
|
+
* });
|
|
87
|
+
*
|
|
88
|
+
* // Send commands using entity IDs
|
|
89
|
+
* await client.sendSwitchCommand("switch-garagedoor", true);
|
|
90
|
+
* await client.sendLightCommand("light-light", { state: true, brightness: 0.8 });
|
|
91
|
+
* await client.sendCoverCommand("cover-door", "open");
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export class EspHomeClient extends EventEmitter {
|
|
95
|
+
// The TCP socket connection to the ESPHome device.
|
|
96
|
+
clientSocket;
|
|
97
|
+
// The data event listener function reference for cleanup.
|
|
98
|
+
dataListener;
|
|
99
|
+
// The hostname or IP address of the ESPHome device.
|
|
100
|
+
host;
|
|
101
|
+
// Logging.
|
|
102
|
+
log;
|
|
103
|
+
// The port number for the ESPHome API connection.
|
|
104
|
+
port;
|
|
105
|
+
// Buffer for accumulating incoming data until complete messages are received.
|
|
106
|
+
recvBuffer;
|
|
107
|
+
// Device information received from the ESPHome device.
|
|
108
|
+
remoteDeviceInfo;
|
|
109
|
+
// Array storing all discovered entities from the device.
|
|
110
|
+
discoveredEntities;
|
|
111
|
+
// Map from entity identifier strings to their numeric keys.
|
|
112
|
+
entityKeys;
|
|
113
|
+
// Map from entity keys to their human-readable names.
|
|
114
|
+
entityNames;
|
|
115
|
+
// Map from entity keys to their type labels.
|
|
116
|
+
entityTypes;
|
|
117
|
+
/**
|
|
118
|
+
* Creates a new ESPHome client instance.
|
|
119
|
+
*
|
|
120
|
+
* @param log - Logging interface.
|
|
121
|
+
* @param host - The hostname or IP address of the ESPHome device.
|
|
122
|
+
* @param port - The port number for the ESPHome API (default: 6053).
|
|
123
|
+
*/
|
|
124
|
+
constructor(log, host, port = 6053) {
|
|
125
|
+
super();
|
|
126
|
+
this.clientSocket = null;
|
|
127
|
+
this.dataListener = null;
|
|
128
|
+
this.discoveredEntities = [];
|
|
129
|
+
this.entityKeys = new Map();
|
|
130
|
+
this.entityNames = new Map();
|
|
131
|
+
this.entityTypes = new Map();
|
|
132
|
+
this.host = host;
|
|
133
|
+
this.log = log;
|
|
134
|
+
this.port = port;
|
|
135
|
+
this.recvBuffer = Buffer.alloc(0);
|
|
136
|
+
this.remoteDeviceInfo = null;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Connect to the ESPHome device and start communication.
|
|
140
|
+
*/
|
|
141
|
+
connect() {
|
|
142
|
+
// Clean up any existing data listener before establishing a new connection.
|
|
143
|
+
this.cleanupDataListener();
|
|
144
|
+
// Create a new TCP connection to the ESPHome device.
|
|
145
|
+
this.clientSocket = createConnection({ host: this.host, port: this.port });
|
|
146
|
+
// Handle successful connection by initiating the handshake process.
|
|
147
|
+
this.clientSocket.on("connect", () => this.handleConnect());
|
|
148
|
+
// Set up the data handler for incoming messages.
|
|
149
|
+
this.dataListener = (chunk) => this.handleData(chunk);
|
|
150
|
+
this.clientSocket.on("data", this.dataListener);
|
|
151
|
+
// Handle socket errors by attempting to reconnect.
|
|
152
|
+
this.clientSocket.once("error", (err) => this.handleSocketError(err));
|
|
153
|
+
// Handle socket closure by attempting to reconnect.
|
|
154
|
+
this.clientSocket.once("close", () => this.handleSocketClose());
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Disconnect from the ESPHome device and cleanup resources.
|
|
158
|
+
*/
|
|
159
|
+
disconnect() {
|
|
160
|
+
// Clean up the data listener.
|
|
161
|
+
this.cleanupDataListener();
|
|
162
|
+
// Destroy the socket connection.
|
|
163
|
+
if (this.clientSocket) {
|
|
164
|
+
this.clientSocket.destroy();
|
|
165
|
+
this.clientSocket = null;
|
|
166
|
+
}
|
|
167
|
+
this.emit("disconnect");
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Handle a newly connected socket.
|
|
171
|
+
*/
|
|
172
|
+
handleConnect() {
|
|
173
|
+
this.log.debug("Connected to " + this.host + ":" + this.port + ".");
|
|
174
|
+
// Send the initial hello request to start the handshake.
|
|
175
|
+
// Prepare the client information string for the hello message.
|
|
176
|
+
const clientInfo = Buffer.from("homebridge-ratgdo", "utf8");
|
|
177
|
+
// Build the hello payload fields.
|
|
178
|
+
const fields = [
|
|
179
|
+
{ fieldNumber: 1, value: clientInfo, wireType: WireType.LENGTH_DELIMITED },
|
|
180
|
+
{ fieldNumber: 2, value: 1, wireType: WireType.VARINT },
|
|
181
|
+
{ fieldNumber: 3, value: 10, wireType: WireType.VARINT }
|
|
182
|
+
];
|
|
183
|
+
// Encode and send the hello request.
|
|
184
|
+
const payload = this.encodeProtoFields(fields);
|
|
185
|
+
this.frameAndSend(MessageType.HELLO_REQUEST, payload);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Handle socket errors.
|
|
189
|
+
*/
|
|
190
|
+
handleSocketError(err) {
|
|
191
|
+
switch (err.code) {
|
|
192
|
+
case "ECONNRESET":
|
|
193
|
+
this.log.error("Connection reset.");
|
|
194
|
+
break;
|
|
195
|
+
case "EHOSTDOWN":
|
|
196
|
+
case "EHOSTUNREACH":
|
|
197
|
+
this.log.error("Ratgdo unreachable.");
|
|
198
|
+
break;
|
|
199
|
+
case "ETIMEDOUT":
|
|
200
|
+
this.log.error("Connection timed out.");
|
|
201
|
+
break;
|
|
202
|
+
default:
|
|
203
|
+
this.log.error("Socket error: %s | %s", err.code, err);
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
this.disconnect();
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Handle socket closure.
|
|
210
|
+
*/
|
|
211
|
+
handleSocketClose() {
|
|
212
|
+
this.log.debug("Socket closed");
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Clean up the data listener if it exists.
|
|
216
|
+
*/
|
|
217
|
+
cleanupDataListener() {
|
|
218
|
+
if (this.dataListener && this.clientSocket) {
|
|
219
|
+
this.clientSocket.off("data", this.dataListener);
|
|
220
|
+
this.dataListener = null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Handle incoming raw data, frame messages, and dispatch.
|
|
225
|
+
*/
|
|
226
|
+
handleData(chunk) {
|
|
227
|
+
// Append the new data chunk to our receive buffer.
|
|
228
|
+
this.recvBuffer = Buffer.concat([this.recvBuffer, chunk]);
|
|
229
|
+
// Process complete messages from the buffer.
|
|
230
|
+
while (this.recvBuffer.length >= MIN_FRAME_SIZE) {
|
|
231
|
+
// Verify the frame starts with the expected sentinel byte.
|
|
232
|
+
if (this.recvBuffer[0] !== 0) {
|
|
233
|
+
this.log.error("Framing error: missing 0x00.");
|
|
234
|
+
this.recvBuffer = Buffer.alloc(0);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// Read the message length as a varint.
|
|
238
|
+
const [length, lenBytes] = this.readVarint(this.recvBuffer, 1);
|
|
239
|
+
// Read the message type as a varint.
|
|
240
|
+
const [type, typeBytes] = this.readVarint(this.recvBuffer, 1 + lenBytes);
|
|
241
|
+
// Calculate the total header size.
|
|
242
|
+
const headerSize = 1 + lenBytes + typeBytes;
|
|
243
|
+
// Check if we have received the complete message payload.
|
|
244
|
+
if (this.recvBuffer.length < (headerSize + length)) {
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
// Extract the message payload.
|
|
248
|
+
const payload = this.recvBuffer.subarray(headerSize, headerSize + length);
|
|
249
|
+
// Process the complete message.
|
|
250
|
+
this.handleMessage(type, payload);
|
|
251
|
+
// Remove the processed message from the receive buffer.
|
|
252
|
+
this.recvBuffer = this.recvBuffer.subarray(headerSize + length);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Dispatch based on message type.
|
|
257
|
+
*/
|
|
258
|
+
handleMessage(type, payload) {
|
|
259
|
+
let epoch, nowBuf;
|
|
260
|
+
// Emit a generic message event for all message types.
|
|
261
|
+
this.emit("message", { payload, type });
|
|
262
|
+
// Handle specific message types.
|
|
263
|
+
switch (type) {
|
|
264
|
+
case MessageType.HELLO_RESPONSE:
|
|
265
|
+
// Send the connect request to complete the handshake.
|
|
266
|
+
this.frameAndSend(MessageType.CONNECT_REQUEST, Buffer.alloc(0));
|
|
267
|
+
break;
|
|
268
|
+
case MessageType.CONNECT_RESPONSE:
|
|
269
|
+
// Query device information once we're connected.
|
|
270
|
+
this.frameAndSend(MessageType.DEVICE_INFO_REQUEST, Buffer.alloc(0));
|
|
271
|
+
// Start entity enumeration after successful connection.
|
|
272
|
+
this.frameAndSend(MessageType.LIST_ENTITIES_REQUEST, Buffer.alloc(0));
|
|
273
|
+
break;
|
|
274
|
+
case MessageType.DISCONNECT_REQUEST:
|
|
275
|
+
// Start entity enumeration after successful connection.
|
|
276
|
+
this.frameAndSend(MessageType.DISCONNECT_RESPONSE, Buffer.alloc(0));
|
|
277
|
+
this.disconnect();
|
|
278
|
+
break;
|
|
279
|
+
case MessageType.DISCONNECT_RESPONSE:
|
|
280
|
+
this.disconnect();
|
|
281
|
+
break;
|
|
282
|
+
case MessageType.DEVICE_INFO_RESPONSE:
|
|
283
|
+
this.handleDeviceInfoResponse(payload);
|
|
284
|
+
this.emit("connect", this.remoteDeviceInfo);
|
|
285
|
+
break;
|
|
286
|
+
case MessageType.LIST_ENTITIES_DONE_RESPONSE:
|
|
287
|
+
// Emit the complete list of discovered entities.
|
|
288
|
+
this.emit("entities", this.discoveredEntities);
|
|
289
|
+
// Now that we know all the entities we have available, subscribe to state updates.
|
|
290
|
+
this.frameAndSend(MessageType.SUBSCRIBE_STATES_REQUEST, Buffer.alloc(0));
|
|
291
|
+
break;
|
|
292
|
+
case MessageType.PING_REQUEST:
|
|
293
|
+
this.log.debug("Received PingRequest, replying");
|
|
294
|
+
// Respond to ping requests to keep the connection alive.
|
|
295
|
+
this.frameAndSend(MessageType.PING_RESPONSE, Buffer.alloc(0));
|
|
296
|
+
// Emit heartbeat event for connection monitoring.
|
|
297
|
+
this.emit("heartbeat");
|
|
298
|
+
break;
|
|
299
|
+
case MessageType.PING_RESPONSE:
|
|
300
|
+
// Emit heartbeat event for connection monitoring.
|
|
301
|
+
this.emit("heartbeat");
|
|
302
|
+
break;
|
|
303
|
+
case MessageType.GET_TIME_REQUEST:
|
|
304
|
+
// We got a time‐sync request from the device; reply with our current epoch.
|
|
305
|
+
this.log.debug("Received GetTimeRequest, replying with current epoch time");
|
|
306
|
+
// Prepare a four-byte little‐endian buffer.
|
|
307
|
+
nowBuf = Buffer.alloc(FIXED32_SIZE);
|
|
308
|
+
// Calculate our time in seconds and encode it in our buffer.
|
|
309
|
+
nowBuf.writeUInt32LE(Math.floor(Date.now() / 1000), 0);
|
|
310
|
+
// Build the protobuf field: field 1, fixed32 wire type, then encode and send the message.
|
|
311
|
+
this.frameAndSend(MessageType.GET_TIME_RESPONSE, this.encodeProtoFields([{ fieldNumber: 1, value: nowBuf, wireType: WireType.FIXED32 }]));
|
|
312
|
+
break;
|
|
313
|
+
case MessageType.GET_TIME_RESPONSE:
|
|
314
|
+
// Decode the fields in the GetTimeResponse payload and extract the epoch_seconds fixed32 field (field 1).
|
|
315
|
+
epoch = this.extractFixed32Field(this.decodeProtobuf(payload), 1);
|
|
316
|
+
if (epoch !== undefined) {
|
|
317
|
+
// Emit a `time` event carrying the returned epoch seconds.
|
|
318
|
+
this.emit("time", epoch);
|
|
319
|
+
this.log.debug("Received GetTimeResponse: epoch seconds", epoch);
|
|
320
|
+
}
|
|
321
|
+
break;
|
|
322
|
+
default:
|
|
323
|
+
// Check if this is a list entities response.
|
|
324
|
+
if (this.isListEntitiesResponse(type)) {
|
|
325
|
+
this.handleListEntity(type, payload);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
// Check if this is a state update.
|
|
329
|
+
if (this.isStateUpdate(type)) {
|
|
330
|
+
this.handleTelemetry(type, payload);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
// Unhandled message type.
|
|
334
|
+
this.log.warn("Unhandled message type: " + type + " | payload: " + payload.toString("hex"));
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Handle device info response from the ESPHome device.
|
|
340
|
+
*/
|
|
341
|
+
handleDeviceInfoResponse(payload) {
|
|
342
|
+
this.log.debug("Received DeviceInfoResponse");
|
|
343
|
+
// Decode the protobuf fields from the payload.
|
|
344
|
+
const fields = this.decodeProtobuf(payload);
|
|
345
|
+
// Build the device info object from the response.
|
|
346
|
+
const info = {};
|
|
347
|
+
// Extract uses_password (field 1).
|
|
348
|
+
info.usesPassword = this.extractNumberField(fields, 1) === 1;
|
|
349
|
+
// Extract name (field 2).
|
|
350
|
+
info.name = this.extractStringField(fields, 2);
|
|
351
|
+
// Extract MAC address (field 3).
|
|
352
|
+
info.macAddress = this.extractStringField(fields, 3);
|
|
353
|
+
// Extract ESPHome version (field 4).
|
|
354
|
+
info.esphomeVersion = this.extractStringField(fields, 4);
|
|
355
|
+
// Extract compilation time (field 5).
|
|
356
|
+
info.compilationTime = this.extractStringField(fields, 5);
|
|
357
|
+
// Extract model (field 6).
|
|
358
|
+
info.model = this.extractStringField(fields, 6);
|
|
359
|
+
// Extract has_deep_sleep (field 7).
|
|
360
|
+
info.hasDeepSleep = this.extractNumberField(fields, 7) === 1;
|
|
361
|
+
// Extract project_name (field 8).
|
|
362
|
+
info.projectName = this.extractStringField(fields, 8);
|
|
363
|
+
// Extract project_version (field 9).
|
|
364
|
+
info.projectVersion = this.extractStringField(fields, 9);
|
|
365
|
+
// Extract webserver_port (field 10).
|
|
366
|
+
info.webserverPort = this.extractNumberField(fields, 10);
|
|
367
|
+
// Extract legacy_bluetooth_proxy_version (field 11).
|
|
368
|
+
info.legacyBluetoothProxyVersion = this.extractNumberField(fields, 11);
|
|
369
|
+
// Extract bluetooth_proxy_feature_flags (field 12).
|
|
370
|
+
info.bluetoothProxyFeatureFlags = this.extractNumberField(fields, 12);
|
|
371
|
+
// Store the remote device info.
|
|
372
|
+
this.remoteDeviceInfo = info;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Return the device information of the connected ESPHome device if available.
|
|
376
|
+
*
|
|
377
|
+
* @returns The device information if available, or `null`.
|
|
378
|
+
*/
|
|
379
|
+
deviceInfo() {
|
|
380
|
+
// Ensure the device information can't be mutated by our caller.
|
|
381
|
+
return this.remoteDeviceInfo ? { ...this.remoteDeviceInfo } : null;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Check if a message type is a list entities response.
|
|
385
|
+
*/
|
|
386
|
+
isListEntitiesResponse(type) {
|
|
387
|
+
return (type >= MessageType.LIST_ENTITIES_BINARY_SENSOR_RESPONSE && type <= MessageType.LIST_ENTITIES_TEXT_SENSOR_RESPONSE) ||
|
|
388
|
+
[MessageType.LIST_ENTITIES_SERVICES_RESPONSE, MessageType.LIST_ENTITIES_NUMBER_RESPONSE, MessageType.LIST_ENTITIES_LOCK_RESPONSE,
|
|
389
|
+
MessageType.LIST_ENTITIES_BUTTON_RESPONSE].includes(type);
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Check if a message type is a state update.
|
|
393
|
+
*/
|
|
394
|
+
isStateUpdate(type) {
|
|
395
|
+
return [MessageType.BINARY_SENSOR_STATE, MessageType.COVER_STATE, MessageType.LIGHT_STATE, MessageType.SENSOR_STATE, MessageType.SWITCH_STATE,
|
|
396
|
+
MessageType.TEXT_SENSOR_STATE, MessageType.NUMBER_STATE, MessageType.LOCK_STATE, MessageType.BUTTON_COMMAND_REQUEST].includes(type);
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Extract entity type label from message type.
|
|
400
|
+
*/
|
|
401
|
+
getEntityTypeLabel(type) {
|
|
402
|
+
return MessageType[type].replace(/^LIST_ENTITIES_/, "").replace(/_RESPONSE$/, "").replace(/_STATE$/, "").toLowerCase();
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Parses a single ListEntities*Response, logs it, and stores it.
|
|
406
|
+
*/
|
|
407
|
+
handleListEntity(type, payload) {
|
|
408
|
+
// Decode the protobuf fields from the payload.
|
|
409
|
+
const fields = this.decodeProtobuf(payload);
|
|
410
|
+
// Extract and validate the entity key.
|
|
411
|
+
const key = this.extractFixed32Field(fields, 2);
|
|
412
|
+
if (key === undefined) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
// Extract and validate the entity name.
|
|
416
|
+
const name = this.extractStringField(fields, 3);
|
|
417
|
+
if (name === undefined) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
// Determine the entity type label from the message type enum.
|
|
421
|
+
const label = this.getEntityTypeLabel(type);
|
|
422
|
+
// Store the entity information in our lookup maps.
|
|
423
|
+
const entityId = (label + "-" + name).replace(/ /g, "_").toLowerCase();
|
|
424
|
+
this.entityKeys.set(entityId, key);
|
|
425
|
+
this.entityNames.set(key, name);
|
|
426
|
+
this.entityTypes.set(key, label);
|
|
427
|
+
// Create an entity object and add it to our discovered entities list.
|
|
428
|
+
const ent = { key, name, type: label };
|
|
429
|
+
this.discoveredEntities.push(ent);
|
|
430
|
+
// Log the entity registration for debugging.
|
|
431
|
+
this.log.debug("Registered entity: [" + key + "] " + name + " (" + label + ") | " + type);
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Decodes a state update, looks up entity info, and emits events.
|
|
435
|
+
*/
|
|
436
|
+
handleTelemetry(type, payload) {
|
|
437
|
+
// Decode the protobuf fields from the payload.
|
|
438
|
+
const fields = this.decodeProtobuf(payload);
|
|
439
|
+
// Extract the entity key from field 1.
|
|
440
|
+
const key = this.extractEntityKey(fields, 1);
|
|
441
|
+
if (key === undefined) {
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
// Look up the entity information using the key.
|
|
445
|
+
const name = this.entityNames.get(key) || ("unknown(" + key + ")");
|
|
446
|
+
const typeLabel = this.entityTypes.get(key) || this.getEntityTypeLabel(type);
|
|
447
|
+
const eventType = typeLabel.toLowerCase();
|
|
448
|
+
// Handle cover state messages specially as they have additional fields.
|
|
449
|
+
if (type === MessageType.COVER_STATE) {
|
|
450
|
+
this.handleCoverState(fields, eventType, name);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
// Handle all other entity types with simpler value extraction.
|
|
454
|
+
const value = this.extractTelemetryValue(fields, 2);
|
|
455
|
+
// Build the telemetry data object.
|
|
456
|
+
const data = { entity: name, type: eventType, value };
|
|
457
|
+
// Emit both the generic telemetry event and the type-specific event.
|
|
458
|
+
this.emit("telemetry", data);
|
|
459
|
+
this.emit(eventType, data);
|
|
460
|
+
this.log.debug("TYPE: " + eventType + " | data: " + JSON.stringify(data));
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Handle cover state telemetry.
|
|
464
|
+
*/
|
|
465
|
+
handleCoverState(fields, eventType, name) {
|
|
466
|
+
// Extract all cover-specific fields.
|
|
467
|
+
const legacyState = this.extractNumberField(fields, 2);
|
|
468
|
+
const position = this.extractTelemetryValue(fields, 3);
|
|
469
|
+
const tilt = this.extractTelemetryValue(fields, 4);
|
|
470
|
+
const currentOperation = this.extractNumberField(fields, 5);
|
|
471
|
+
const deviceId = this.extractNumberField(fields, 6);
|
|
472
|
+
// Build a comprehensive cover state data object.
|
|
473
|
+
const data = {
|
|
474
|
+
currentOperation,
|
|
475
|
+
deviceId,
|
|
476
|
+
entity: name,
|
|
477
|
+
legacyState,
|
|
478
|
+
position,
|
|
479
|
+
tilt,
|
|
480
|
+
type: eventType
|
|
481
|
+
};
|
|
482
|
+
// Emit both the generic telemetry event and the type-specific event.
|
|
483
|
+
this.emit("telemetry", data);
|
|
484
|
+
this.emit(eventType, data);
|
|
485
|
+
this.log.debug("TYPE: " + eventType + " | data: " + JSON.stringify(data));
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Extract entity key from protobuf fields.
|
|
489
|
+
*/
|
|
490
|
+
extractEntityKey(fields, fieldNum) {
|
|
491
|
+
const rawKey = fields[fieldNum]?.[0];
|
|
492
|
+
if (!rawKey) {
|
|
493
|
+
return undefined;
|
|
494
|
+
}
|
|
495
|
+
// Handle both Buffer and number types.
|
|
496
|
+
if (Buffer.isBuffer(rawKey)) {
|
|
497
|
+
return rawKey.readUInt32LE(0);
|
|
498
|
+
}
|
|
499
|
+
if (typeof rawKey === "number") {
|
|
500
|
+
return rawKey;
|
|
501
|
+
}
|
|
502
|
+
return undefined;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Extract fixed32 field from protobuf fields.
|
|
506
|
+
*/
|
|
507
|
+
extractFixed32Field(fields, fieldNum) {
|
|
508
|
+
const rawBuf = fields[fieldNum]?.[0];
|
|
509
|
+
if (!Buffer.isBuffer(rawBuf) || rawBuf.length !== FIXED32_SIZE) {
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
return rawBuf.readUInt32LE(0);
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Extract string field from protobuf fields.
|
|
516
|
+
*/
|
|
517
|
+
extractStringField(fields, fieldNum) {
|
|
518
|
+
const rawBuf = fields[fieldNum]?.[0];
|
|
519
|
+
if (!Buffer.isBuffer(rawBuf)) {
|
|
520
|
+
return undefined;
|
|
521
|
+
}
|
|
522
|
+
return rawBuf.toString("utf8");
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Extract number field from protobuf fields.
|
|
526
|
+
*/
|
|
527
|
+
extractNumberField(fields, fieldNum) {
|
|
528
|
+
const raw = fields[fieldNum]?.[0];
|
|
529
|
+
return typeof raw === "number" ? raw : undefined;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Extract telemetry value from protobuf fields.
|
|
533
|
+
*/
|
|
534
|
+
extractTelemetryValue(fields, fieldNum) {
|
|
535
|
+
const valRaw = fields[fieldNum]?.[0];
|
|
536
|
+
if (Buffer.isBuffer(valRaw)) {
|
|
537
|
+
// Interpret 4-byte buffers as float32, others as UTF-8 strings.
|
|
538
|
+
return valRaw.length === FIXED32_SIZE ? valRaw.readFloatLE(0) : valRaw.toString("utf8");
|
|
539
|
+
}
|
|
540
|
+
return valRaw;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Frames a raw protobuf payload with the 0x00 sentinel, length, and message type.
|
|
544
|
+
*/
|
|
545
|
+
frameAndSend(type, payload) {
|
|
546
|
+
// Construct the message header with sentinel, length, and type.
|
|
547
|
+
const header = Buffer.concat([Buffer.from([0x00]), this.encodeVarint(payload.length), this.encodeVarint(type)]);
|
|
548
|
+
// Write the complete framed message to the socket.
|
|
549
|
+
if (this.clientSocket) {
|
|
550
|
+
this.clientSocket.write(Buffer.concat([header, payload]));
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Encode protobuf fields into a buffer.
|
|
555
|
+
*/
|
|
556
|
+
encodeProtoFields(fields) {
|
|
557
|
+
const parts = [];
|
|
558
|
+
let buf;
|
|
559
|
+
for (const field of fields) {
|
|
560
|
+
// Encode the field tag.
|
|
561
|
+
parts.push(this.encodeVarint((field.fieldNumber << 3) | field.wireType));
|
|
562
|
+
// Encode the field value based on wire type.
|
|
563
|
+
switch (field.wireType) {
|
|
564
|
+
case WireType.VARINT:
|
|
565
|
+
parts.push(this.encodeVarint(field.value));
|
|
566
|
+
break;
|
|
567
|
+
case WireType.LENGTH_DELIMITED:
|
|
568
|
+
buf = field.value;
|
|
569
|
+
parts.push(this.encodeVarint(buf.length));
|
|
570
|
+
parts.push(buf);
|
|
571
|
+
break;
|
|
572
|
+
case WireType.FIXED32:
|
|
573
|
+
buf = Buffer.alloc(FIXED32_SIZE);
|
|
574
|
+
if (typeof field.value === "number") {
|
|
575
|
+
buf.writeUInt32LE(field.value, 0);
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
field.value.copy(buf);
|
|
579
|
+
}
|
|
580
|
+
parts.push(buf);
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return Buffer.concat(parts);
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Build key field as fixed32 for command requests.
|
|
588
|
+
*/
|
|
589
|
+
buildKeyField(key) {
|
|
590
|
+
return { fieldNumber: 1, value: key, wireType: WireType.FIXED32 };
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Get entity key by ID.
|
|
594
|
+
*
|
|
595
|
+
* @param id - The entity ID to look up.
|
|
596
|
+
*
|
|
597
|
+
* @returns The entity key or `null` if not found.
|
|
598
|
+
*/
|
|
599
|
+
getEntityKey(id) {
|
|
600
|
+
return this.entityKeys.get(id) ?? null;
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Log all registered entity IDs for debugging.
|
|
604
|
+
* Logs entities grouped by type with their names and keys.
|
|
605
|
+
*/
|
|
606
|
+
logAllEntityIds() {
|
|
607
|
+
this.log.warn("Registered Entity IDs:");
|
|
608
|
+
for (const [type, ids] of Object.entries(this.getAvailableEntityIds())) {
|
|
609
|
+
this.log.warn(" " + type + ":");
|
|
610
|
+
for (const id of ids) {
|
|
611
|
+
const entity = this.getEntityById(id);
|
|
612
|
+
if (entity) {
|
|
613
|
+
this.log.warn(" " + id + " => " + entity.name + " (key: " + entity.key + ")");
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Get entity information by ID.
|
|
620
|
+
*
|
|
621
|
+
* @param id - The entity ID to look up.
|
|
622
|
+
*
|
|
623
|
+
* @returns The entity information or `null` if not found.
|
|
624
|
+
*/
|
|
625
|
+
getEntityById(id) {
|
|
626
|
+
const key = this.entityKeys.get(id);
|
|
627
|
+
if (!key) {
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
const name = this.entityNames.get(key);
|
|
631
|
+
const type = this.entityTypes.get(key);
|
|
632
|
+
if (!name || !type) {
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
return { key, name, type };
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Check if an entity ID exists.
|
|
639
|
+
*
|
|
640
|
+
* @param id - The entity ID to check.
|
|
641
|
+
*
|
|
642
|
+
* @returns `true` if the entity exists, `false` otherwise.
|
|
643
|
+
*/
|
|
644
|
+
hasEntity(id) {
|
|
645
|
+
return this.entityKeys.has(id);
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Get all available entity IDs grouped by type.
|
|
649
|
+
*
|
|
650
|
+
* @returns Object with entity types as keys and arrays of IDs as values.
|
|
651
|
+
*/
|
|
652
|
+
getAvailableEntityIds() {
|
|
653
|
+
const result = {};
|
|
654
|
+
for (const id of this.entityKeys.keys()) {
|
|
655
|
+
const type = id.split("-")[0];
|
|
656
|
+
result[type] ??= [];
|
|
657
|
+
result[type].push(id);
|
|
658
|
+
}
|
|
659
|
+
return result;
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Get all entities with their IDs.
|
|
663
|
+
*
|
|
664
|
+
* @returns Array of entities with their corresponding IDs.
|
|
665
|
+
*/
|
|
666
|
+
getEntitiesWithIds() {
|
|
667
|
+
return this.discoveredEntities.map(entity => {
|
|
668
|
+
const id = (entity.type + "-" + entity.name).replace(/ /g, "_").toLowerCase();
|
|
669
|
+
return { ...entity, id };
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Send a ping request to the device to heartbeat the connection.
|
|
674
|
+
*/
|
|
675
|
+
sendPing() {
|
|
676
|
+
this.frameAndSend(MessageType.PING_REQUEST, Buffer.alloc(0));
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Sends a SwitchCommandRequest for the given entity ID and on/off state.
|
|
680
|
+
*
|
|
681
|
+
* @param id - The entity ID (format: "switch-entityname").
|
|
682
|
+
* @param state - `true` for on, `false` for off.
|
|
683
|
+
*/
|
|
684
|
+
sendSwitchCommand(id, state) {
|
|
685
|
+
// Look up the entity key using the provided ID.
|
|
686
|
+
const key = this.entityKeys.get(id);
|
|
687
|
+
// Log debugging information.
|
|
688
|
+
this.log.debug("sendSwitchCommand - ID: " + id + " | KEY: " + key + " | state: " + state);
|
|
689
|
+
// Return early if the entity key is not found.
|
|
690
|
+
if (!key) {
|
|
691
|
+
this.log.warn("Entity key not found for ID: " + id);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
// Build the protobuf fields.
|
|
695
|
+
const fields = [this.buildKeyField(key), { fieldNumber: 2, value: state ? 1 : 0, wireType: WireType.VARINT }];
|
|
696
|
+
// Encode and send the switch command request.
|
|
697
|
+
const payload = this.encodeProtoFields(fields);
|
|
698
|
+
this.frameAndSend(MessageType.SWITCH_COMMAND_REQUEST, payload);
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Sends a ButtonCommandRequest to press a button entity.
|
|
702
|
+
*
|
|
703
|
+
* @param id - The entity ID (format: "button-entityname").
|
|
704
|
+
*/
|
|
705
|
+
sendButtonCommand(id) {
|
|
706
|
+
// Look up the entity key using the provided ID.
|
|
707
|
+
const key = this.entityKeys.get(id);
|
|
708
|
+
// Log debugging information.
|
|
709
|
+
this.log.debug("sendButtonCommand - ID: " + id + " | KEY: " + key);
|
|
710
|
+
// Return early if the entity key is not found.
|
|
711
|
+
if (!key) {
|
|
712
|
+
this.log.warn("Entity key not found for ID: " + id);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
// Build the protobuf fields.
|
|
716
|
+
const fields = [this.buildKeyField(key)];
|
|
717
|
+
// Encode and send the button command request.
|
|
718
|
+
const payload = this.encodeProtoFields(fields);
|
|
719
|
+
this.frameAndSend(MessageType.BUTTON_COMMAND_REQUEST, payload);
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Sends a CoverCommandRequest for the given entity ID.
|
|
723
|
+
*
|
|
724
|
+
* @param id - The entity ID (format: "cover-entityname").
|
|
725
|
+
* @param options - Command options (at least one option must be provided).
|
|
726
|
+
* @param options.command - The command: "open", "close", or "stop" (optional).
|
|
727
|
+
* @param options.position - Target position 0.0-1.0 where 0 is closed, 1 is open (optional).
|
|
728
|
+
* @param options.tilt - Target tilt 0.0-1.0 where 0 is closed, 1 is open (optional).
|
|
729
|
+
*
|
|
730
|
+
* @example
|
|
731
|
+
* ```typescript
|
|
732
|
+
* // Send a simple command
|
|
733
|
+
* await client.sendCoverCommand("cover-garagedoor", { command: "open" });
|
|
734
|
+
*
|
|
735
|
+
* // Set to specific position
|
|
736
|
+
* await client.sendCoverCommand("cover-garagedoor", { position: 0.5 }); // 50% open
|
|
737
|
+
*
|
|
738
|
+
* // Set position and tilt
|
|
739
|
+
* await client.sendCoverCommand("cover-blinds", { position: 1.0, tilt: 0.25 });
|
|
740
|
+
* ```
|
|
741
|
+
*/
|
|
742
|
+
sendCoverCommand(id, options) {
|
|
743
|
+
// Validate that at least one option is provided.
|
|
744
|
+
if (!options.command && typeof options.position !== "number" && typeof options.tilt !== "number") {
|
|
745
|
+
this.log.warn("sendCoverCommand requires at least one option: command, position, or tilt");
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
// Look up the entity key using the provided ID.
|
|
749
|
+
const key = this.entityKeys.get(id);
|
|
750
|
+
// Log debugging information.
|
|
751
|
+
this.log.debug("sendCoverCommand - ID: " + id + " | KEY: " + key + " | options: " + JSON.stringify(options));
|
|
752
|
+
// Return early if the entity key is not found.
|
|
753
|
+
if (!key) {
|
|
754
|
+
this.log.warn("Entity key not found for ID: " + id);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
// Build the protobuf fields.
|
|
758
|
+
const fields = [this.buildKeyField(key)];
|
|
759
|
+
// Add legacy command fields if a command is specified.
|
|
760
|
+
if (options.command) {
|
|
761
|
+
// Map user-friendly commands to legacy enum values.
|
|
762
|
+
const cmdMap = { close: 1, open: 0, stop: 2 };
|
|
763
|
+
fields.push({ fieldNumber: 2, value: 1, wireType: WireType.VARINT }, // has_legacy_command
|
|
764
|
+
{ fieldNumber: 3, value: cmdMap[options.command], wireType: WireType.VARINT } // legacy_command
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
// Add position field if specified.
|
|
768
|
+
if (typeof options.position === "number") {
|
|
769
|
+
fields.push({ fieldNumber: 4, value: 1, wireType: WireType.VARINT } // has_position
|
|
770
|
+
);
|
|
771
|
+
// Create position buffer as float32.
|
|
772
|
+
const positionBuf = Buffer.alloc(FIXED32_SIZE);
|
|
773
|
+
positionBuf.writeFloatLE(options.position, 0);
|
|
774
|
+
fields.push({ fieldNumber: 5, value: positionBuf, wireType: WireType.FIXED32 } // position
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
// Add tilt field if specified.
|
|
778
|
+
if (typeof options.tilt === "number") {
|
|
779
|
+
fields.push({ fieldNumber: 6, value: 1, wireType: WireType.VARINT } // has_tilt
|
|
780
|
+
);
|
|
781
|
+
// Create tilt buffer as float32.
|
|
782
|
+
const tiltBuf = Buffer.alloc(FIXED32_SIZE);
|
|
783
|
+
tiltBuf.writeFloatLE(options.tilt, 0);
|
|
784
|
+
fields.push({ fieldNumber: 7, value: tiltBuf, wireType: WireType.FIXED32 } // tilt
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
// Encode and send the cover command request.
|
|
788
|
+
const payload = this.encodeProtoFields(fields);
|
|
789
|
+
this.frameAndSend(MessageType.COVER_COMMAND_REQUEST, payload);
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Sends a LightCommandRequest to turn on/off and optionally set brightness.
|
|
793
|
+
*
|
|
794
|
+
* @param id - The entity ID (format: "light-entityname").
|
|
795
|
+
* @param options - Command options.
|
|
796
|
+
* @param options.state - `true` for on, `false` for off (optional).
|
|
797
|
+
* @param options.brightness - Brightness level 0.0-1.0 (optional).
|
|
798
|
+
*/
|
|
799
|
+
sendLightCommand(id, options) {
|
|
800
|
+
// Look up the entity key using the provided ID.
|
|
801
|
+
const key = this.entityKeys.get(id);
|
|
802
|
+
// Log debugging information.
|
|
803
|
+
this.log.debug("sendLightCommand - ID: " + id + " | KEY: " + key + " | options: " + JSON.stringify(options));
|
|
804
|
+
// Return early if the entity key is not found.
|
|
805
|
+
if (!key) {
|
|
806
|
+
this.log.warn("Entity key not found for ID: " + id);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
// Start building the protobuf fields.
|
|
810
|
+
const fields = [this.buildKeyField(key)];
|
|
811
|
+
// Add state fields if a state is specified.
|
|
812
|
+
if (options.state !== undefined) {
|
|
813
|
+
fields.push({ fieldNumber: 2, value: 1, wireType: WireType.VARINT }, // has_state
|
|
814
|
+
{ fieldNumber: 3, value: options.state ? 1 : 0, wireType: WireType.VARINT } // state
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
// Add brightness fields if brightness is specified.
|
|
818
|
+
if (typeof options.brightness === "number") {
|
|
819
|
+
fields.push({ fieldNumber: 4, value: 1, wireType: WireType.VARINT } // has_brightness
|
|
820
|
+
);
|
|
821
|
+
// Create brightness buffer.
|
|
822
|
+
const brightnessBuf = Buffer.alloc(FIXED32_SIZE);
|
|
823
|
+
brightnessBuf.writeFloatLE(options.brightness, 0);
|
|
824
|
+
fields.push({ fieldNumber: 5, value: brightnessBuf, wireType: WireType.FIXED32 } // brightness
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
// Encode and send the light command request.
|
|
828
|
+
const payload = this.encodeProtoFields(fields);
|
|
829
|
+
this.frameAndSend(MessageType.LIGHT_COMMAND_REQUEST, payload);
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Sends a LockCommandRequest to lock or unlock the given entity ID.
|
|
833
|
+
*
|
|
834
|
+
* @param id - The entity ID (format: "lock-entityname").
|
|
835
|
+
* @param command - The command to send: "lock" or "unlock".
|
|
836
|
+
* @param code - Optional unlock code.
|
|
837
|
+
*/
|
|
838
|
+
sendLockCommand(id, command, code) {
|
|
839
|
+
// Look up the entity key using the provided ID.
|
|
840
|
+
const key = this.entityKeys.get(id);
|
|
841
|
+
// Log debugging information.
|
|
842
|
+
this.log.debug("sendLockCommand - ID: " + id + " | KEY: " + key + " | command: " + command);
|
|
843
|
+
// Return early if the entity key is not found.
|
|
844
|
+
if (!key) {
|
|
845
|
+
this.log.warn("Entity key not found for ID: " + id);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
// Map user-friendly commands to enum values.
|
|
849
|
+
const cmdMap = { lock: 1, unlock: 0 };
|
|
850
|
+
// Build the protobuf fields.
|
|
851
|
+
const fields = [
|
|
852
|
+
this.buildKeyField(key),
|
|
853
|
+
{ fieldNumber: 2, value: cmdMap[command], wireType: WireType.VARINT } // command
|
|
854
|
+
];
|
|
855
|
+
// Add the optional code field if provided.
|
|
856
|
+
if (code !== undefined) {
|
|
857
|
+
const codeBuf = Buffer.from(code, "utf8");
|
|
858
|
+
fields.push({ fieldNumber: 3, value: codeBuf, wireType: WireType.LENGTH_DELIMITED } // code
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
// Encode and send the lock command request.
|
|
862
|
+
const payload = this.encodeProtoFields(fields);
|
|
863
|
+
this.frameAndSend(MessageType.LOCK_COMMAND_REQUEST, payload);
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Encode an integer as a VarInt (protobuf-style).
|
|
867
|
+
*/
|
|
868
|
+
encodeVarint(value) {
|
|
869
|
+
// Initialize an array to accumulate the encoded bytes.
|
|
870
|
+
const bytes = [];
|
|
871
|
+
// Loop through the value, seven bits at a time, until all bits are consumed.
|
|
872
|
+
for (let v = value;; v >>>= 7) {
|
|
873
|
+
// Extract the lowest 7 bits of the current value chunk.
|
|
874
|
+
const bytePart = v & 0x7F;
|
|
875
|
+
// Determine if there are more bits left beyond this chunk.
|
|
876
|
+
const hasMore = (v >>> 7) !== 0;
|
|
877
|
+
// If there are more chunks, set the MSB (continuation) bit; otherwise leave it clear.
|
|
878
|
+
const byte = hasMore ? (bytePart | 0x80) : bytePart;
|
|
879
|
+
// Append this byte into our buffer array.
|
|
880
|
+
bytes.push(byte);
|
|
881
|
+
// If this was the final chunk (no more bits), exit the loop.
|
|
882
|
+
if (!hasMore) {
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
// Convert the array of byte values into a Buffer and return it.
|
|
887
|
+
return Buffer.from(bytes);
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Read a VarInt from buffer at offset; returns [value, bytesRead].
|
|
891
|
+
*/
|
|
892
|
+
readVarint(buffer, offset) {
|
|
893
|
+
// Accumulator for the decoded integer result.
|
|
894
|
+
let result = 0;
|
|
895
|
+
// Counter for how many bytes we've consumed.
|
|
896
|
+
let bytesRead = 0;
|
|
897
|
+
// Read byte-by-byte, adding 7 bits at each step, until the continuation bit is clear.
|
|
898
|
+
for (let shift = 0;; shift += 7) {
|
|
899
|
+
// Fetch the next raw byte from the buffer.
|
|
900
|
+
const byte = buffer[offset + bytesRead];
|
|
901
|
+
// Mask off the continuation bit and merge into the result at the correct position.
|
|
902
|
+
result |= (byte & 0x7F) << shift;
|
|
903
|
+
// Advance our byte counter.
|
|
904
|
+
bytesRead++;
|
|
905
|
+
// If the continuation bit (0x80) is not set, we're done.
|
|
906
|
+
if ((byte & 0x80) === 0) {
|
|
907
|
+
break;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
// Return the decoded integer and the number of bytes we consumed.
|
|
911
|
+
return [result, bytesRead];
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Decode a simple protobuf message into a map of field numbers to values.
|
|
915
|
+
*/
|
|
916
|
+
decodeProtobuf(buffer) {
|
|
917
|
+
// Initialize the map from field numbers to arrays of decoded values.
|
|
918
|
+
const fields = {};
|
|
919
|
+
// Iterate through the buffer by manually advancing the offset.
|
|
920
|
+
for (let offset = 0; offset < buffer.length; /* offset updated in cases */) {
|
|
921
|
+
let len;
|
|
922
|
+
let lenLen;
|
|
923
|
+
let v;
|
|
924
|
+
let value;
|
|
925
|
+
let vLen;
|
|
926
|
+
// Read the next varint as the tag (combines field number and wire type).
|
|
927
|
+
const [tag, tagLen] = this.readVarint(buffer, offset);
|
|
928
|
+
// Advance past the tag bytes.
|
|
929
|
+
offset += tagLen;
|
|
930
|
+
// Extract the field number (upper bits of tag).
|
|
931
|
+
const fieldNum = tag >>> 3;
|
|
932
|
+
// Extract the wire type (lower 3 bits of tag).
|
|
933
|
+
const wireType = tag & 0x07;
|
|
934
|
+
// Decode the payload based on its wire type.
|
|
935
|
+
switch (wireType) {
|
|
936
|
+
case WireType.VARINT:
|
|
937
|
+
// Read a varint payload.
|
|
938
|
+
[v, vLen] = this.readVarint(buffer, offset);
|
|
939
|
+
// Assign the numeric result.
|
|
940
|
+
value = v;
|
|
941
|
+
// Advance past the varint bytes.
|
|
942
|
+
offset += vLen;
|
|
943
|
+
break;
|
|
944
|
+
case WireType.FIXED64:
|
|
945
|
+
// Read a 64-bit little-endian double.
|
|
946
|
+
value = buffer.readDoubleLE(offset);
|
|
947
|
+
// Advance by eight bytes.
|
|
948
|
+
offset += 8;
|
|
949
|
+
break;
|
|
950
|
+
case WireType.LENGTH_DELIMITED:
|
|
951
|
+
// Read the length prefix as a varint.
|
|
952
|
+
[len, lenLen] = this.readVarint(buffer, offset);
|
|
953
|
+
// Advance past the length prefix.
|
|
954
|
+
offset += lenLen;
|
|
955
|
+
// Slice out the next len bytes as a Buffer.
|
|
956
|
+
value = buffer.subarray(offset, offset + len);
|
|
957
|
+
// Advance past the length-delimited payload.
|
|
958
|
+
offset += len;
|
|
959
|
+
break;
|
|
960
|
+
case WireType.FIXED32:
|
|
961
|
+
// For 32-bit fields, return the raw bytes for caller interpretation.
|
|
962
|
+
value = buffer.subarray(offset, offset + 4);
|
|
963
|
+
// Advance by four bytes.
|
|
964
|
+
offset += 4;
|
|
965
|
+
break;
|
|
966
|
+
default:
|
|
967
|
+
// Warn about unsupported wire types and return what's decoded so far.
|
|
968
|
+
this.log.warn("Unsupported wire type " + wireType + ".");
|
|
969
|
+
return fields;
|
|
970
|
+
}
|
|
971
|
+
// Ensure there is an array to hold this field's values.
|
|
972
|
+
if (!fields[fieldNum]) {
|
|
973
|
+
fields[fieldNum] = [];
|
|
974
|
+
}
|
|
975
|
+
// Append the decoded value for this field.
|
|
976
|
+
fields[fieldNum].push(value);
|
|
977
|
+
}
|
|
978
|
+
// Return the completed map of field numbers to value arrays.
|
|
979
|
+
return fields;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
//# sourceMappingURL=ratgdo-api.js.map
|