@webarray/esphome-native-api
Version:
TypeScript/Node.js client for ESPHome native API with encryption and deep sleep support
646 lines • 26.9 kB
JavaScript
"use strict";
/**
* Encrypted Connection Handler for ESPHome Native API
* Extends the base connection with noise protocol encryption
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.EncryptedConnection = void 0;
const net_1 = require("net");
const eventemitter3_1 = require("eventemitter3");
const p_retry_1 = __importDefault(require("p-retry"));
const debug_1 = __importDefault(require("debug"));
const protocol_1 = require("../utils/protocol");
const noise_encryption_1 = require("./noise-encryption");
const types_1 = require("../types");
const debug = (0, debug_1.default)('esphome:encrypted-connection');
// Frame types for encrypted communication
// ESPHome uses frame type 1 for ALL encrypted frames (both handshake and data)
const FRAME_TYPE = 0x01;
class EncryptedConnection extends eventemitter3_1.EventEmitter {
constructor(options) {
super();
this.socket = null;
this.state = {
connected: false,
authenticated: false,
};
this.isReconnecting = false;
this.isDestroyed = false;
this.encryptionBuffer = Buffer.alloc(0);
this.encryptionEstablished = false;
this.expectedDisconnect = false;
this.hasDeepSleep = false;
this.options = {
host: options.host,
port: options.port || 6053,
password: options.password || '',
clientInfo: options.clientInfo || 'ESPHome TypeScript Client',
reconnect: options.reconnect !== false,
reconnectInterval: options.reconnectInterval || 5000,
pingInterval: options.pingInterval || 20000,
pingTimeout: options.pingTimeout || 5000,
connectTimeout: options.connectTimeout || 10000,
encryptionKey: options.encryptionKey || '',
expectedServerName: options.expectedServerName || '',
};
this.protocol = new protocol_1.ProtocolHandler();
// Initialize noise encryption if key is provided
if (this.options.encryptionKey) {
this.noise = new noise_encryption_1.NoiseEncryption(this.options.encryptionKey);
debug('Encryption will be initialized for connection to %s:%d', this.options.host, this.options.port);
}
debug('Encrypted connection initialized for %s:%d', this.options.host, this.options.port);
}
/**
* Connect to the ESPHome device
*/
async connect() {
if (this.isDestroyed) {
throw new types_1.ConnectionError('Connection has been destroyed');
}
if (this.state.connected) {
debug('Already connected');
return;
}
// Initialize noise encryption if needed (WebAssembly loading)
if (this.noise && !this.noise.isInitialized) {
debug('Initializing noise encryption WebAssembly');
await this.noise.initialize();
debug('Noise encryption initialized');
}
debug('Connecting to %s:%d', this.options.host, this.options.port);
try {
await (0, p_retry_1.default)(async () => {
await this.establishConnection();
}, {
retries: this.options.reconnect ? 3 : 0,
minTimeout: 1000,
maxTimeout: 5000,
onFailedAttempt: (error) => {
debug('Connection attempt %d failed: %s', error.attemptNumber, error.message);
},
});
}
catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
debug('Failed to connect: %s', err.message);
throw new types_1.ConnectionError(`Failed to connect: ${err.message}`);
}
}
/**
* Establish the actual TCP connection
*/
async establishConnection() {
return new Promise((resolve, reject) => {
this.cleanup();
const socket = new net_1.Socket();
const connectTimeout = setTimeout(() => {
socket.destroy();
reject(new types_1.ConnectionError('Connection timeout'));
}, this.options.connectTimeout);
connectTimeout.unref();
socket.once('connect', async () => {
clearTimeout(connectTimeout);
debug('TCP connection established');
this.socket = socket;
this.setupSocketHandlers();
this.updateState({ connected: true, authenticated: false });
// Perform noise handshake if encryption is enabled
if (this.noise) {
try {
await this.performNoiseHandshake();
this.encryptionEstablished = true;
this.emit('encryptionEstablished');
debug('Encryption established');
}
catch (error) {
debug('Noise handshake failed: %s', error);
socket.destroy();
reject(new types_1.ConnectionError(`Encryption handshake failed: ${error}`));
return;
}
}
this.emit('connect');
this.startPingTimer();
resolve();
});
socket.once('error', (error) => {
clearTimeout(connectTimeout);
debug('Socket error: %s', error.message);
reject(new types_1.ConnectionError(`Socket error: ${error.message}`));
});
socket.connect(this.options.port, this.options.host);
}
/**
* Perform the noise protocol handshake
*/
,
/**
* Perform the noise protocol handshake
*/
private, async, performNoiseHandshake(), Promise < void > {
: .socket
});
{
throw new Error('Socket not connected');
}
// eslint-disable-next-line @typescript-eslint/no-var-requires
const createNoise = require('@richardhopton/noise-c.wasm');
const encryptionKey = Buffer.from(this.options.encryptionKey, 'base64');
const socket = this.socket;
return new Promise((resolve, reject) => {
// Create noise instance directly in the handshake flow
createNoise((noise) => {
debug('Noise library loaded in handshake');
// Create and initialize handshake state
let client;
let encryptor;
let decryptor;
try {
client = noise.HandshakeState('Noise_NNpsk0_25519_ChaChaPoly_SHA256', noise.constants.NOISE_ROLE_INITIATOR);
client.Initialize(new Uint8Array(Buffer.from('NoiseAPIInit\x00\x00')), null, null, new Uint8Array(encryptionKey));
debug('Handshake state initialized in performNoiseHandshake');
}
catch (error) {
reject(new Error('Failed to initialize handshake: ' + error));
return;
}
const timeout = setTimeout(() => {
reject(new Error('Handshake timeout'));
}, 5000);
timeout.unref();
let handshakeStep = 0; // 0 = waiting for hello, 1 = waiting for handshake response
// Handler for handshake response
const handleHandshakeData = async (data) => {
debug('handleHandshakeData called, data length=%d, encryptionEstablished=%s, handshakeStep=%d', data.length, this.encryptionEstablished, handshakeStep);
try {
this.encryptionBuffer = Buffer.concat([this.encryptionBuffer, data]);
// Parse frame header
while (this.encryptionBuffer.length >= 3) {
const frameType = this.encryptionBuffer[0];
const frameLength = this.encryptionBuffer.readUInt16BE(1);
if (this.encryptionBuffer.length < 3 + frameLength) {
// Not enough data yet
return;
}
if (frameType !== FRAME_TYPE) {
throw new Error(`Expected frame type 1, got ${frameType}`);
}
const frameData = this.encryptionBuffer.slice(3, 3 + frameLength);
this.encryptionBuffer = this.encryptionBuffer.slice(3 + frameLength);
// Check if we're done with handshake and processing encrypted data
if (this.encryptionEstablished && this.decryptor) {
// We're in encrypted mode - decrypt and process the message
debug('Received encrypted frame, length=%d', frameData.length);
try {
const decryptedData = Buffer.from(this.decryptor.DecryptWithAd([], new Uint8Array(frameData)));
debug('Decrypted data, length=%d', decryptedData.length);
// For encrypted connections, messages use a different format:
// 2 bytes: message type (big-endian)
// 2 bytes: message length (big-endian)
// N bytes: protobuf data
if (decryptedData.length < 4) {
debug('Decrypted data too short');
return;
}
const messageType = decryptedData.readUInt16BE(0);
const messageLength = decryptedData.readUInt16BE(2);
const messageData = decryptedData.slice(4, 4 + messageLength);
debug('Received encrypted message type %d, length %d', messageType, messageLength);
const message = {
type: messageType,
data: messageData,
};
this.handleMessage(message);
}
catch (decryptError) {
debug('Failed to decrypt message: %s', decryptError);
throw decryptError;
}
}
else if (handshakeStep === 0) {
// Process server hello
debug('Received server hello, length=%d', frameData.length);
const chosenProto = frameData[0];
if (chosenProto !== 1) {
throw new Error(`Unknown protocol selected by server: ${chosenProto}`);
}
// Check server name if expected
if (this.options.expectedServerName) {
const nullIndex = frameData.indexOf(0, 1);
if (nullIndex > 1) {
const serverName = frameData.slice(1, nullIndex).toString();
if (serverName !== this.options.expectedServerName) {
throw new Error(`Server name mismatch: expected ${this.options.expectedServerName}, got ${serverName}`);
}
}
}
// Send actual handshake message using the client instance directly
const handshakeMsg = client.WriteMessage();
// Prepend 0 byte as in reference implementation
const msgWithHeader = Buffer.concat([Buffer.from([0]), Buffer.from(handshakeMsg)]);
const frame = this.createHandshakeFrame(msgWithHeader);
socket.write(frame);
debug('Sent handshake message, length=%d', msgWithHeader.length);
handshakeStep = 1;
}
else if (handshakeStep === 1) {
// Process handshake response
debug('Received handshake response, length=%d', frameData.length);
const header = frameData[0];
const message = frameData.slice(1);
if (header !== 0) {
throw new Error(`Handshake failure: ${message.toString()}`);
}
// Process handshake message 2 using the client instance directly
client.ReadMessage(new Uint8Array(message), true);
// Split into send and receive ciphers
[encryptor, decryptor] = client.Split();
// Store the ciphers directly on this instance for later use
this.encryptor = encryptor;
this.decryptor = decryptor;
// Store the ciphers for encrypted message handling
this.encryptionEstablished = true;
// Clean up timeout but keep the data handler
clearTimeout(timeout);
// Don't remove the data handler - we'll continue using it for encrypted messages
// The handler will now process encrypted data frames
debug('Encryption established, socket connected: %s, listeners: %d', socket.readyState === 'open', socket.listenerCount('data'));
this.emit('encryptionEstablished');
resolve();
}
}
}
catch (error) {
clearTimeout(timeout);
if (this.socket) {
this.socket.off('data', handleHandshakeData);
}
reject(error);
}
};
if (socket) {
socket.on('data', handleHandshakeData);
debug('Socket data handler attached, listener count: %d', socket.listenerCount('data'));
// Send initial hello (empty frame) as in reference
const helloFrame = this.createHandshakeFrame(Buffer.alloc(0));
socket.write(helloFrame);
debug('Sent hello frame');
// Add error handler to catch socket issues
socket.on('error', (err) => {
debug('Socket error during handshake: %s', err.message);
});
}
}); // End of createNoise callback
}); // End of Promise
}
/**
* Create a handshake frame
*/
createHandshakeFrame(data) {
const frame = Buffer.alloc(3 + data.length);
frame[0] = FRAME_TYPE;
frame.writeUInt16BE(data.length, 1);
data.copy(frame, 3);
return frame;
}
/**
* Create an encrypted data frame
*/
createDataFrame(data) {
const frame = Buffer.alloc(3 + data.length);
frame[0] = FRAME_TYPE; // Use same frame type as handshake
frame.writeUInt16BE(data.length, 1);
data.copy(frame, 3);
return frame;
}
/**
* Setup socket event handlers (for non-encrypted connections)
*/
setupSocketHandlers() {
if (!this.socket || this.noise)
return;
this.socket.on('data', (data) => {
try {
const messages = this.protocol.addData(data);
for (const message of messages) {
debug('Received message type %d', message.type);
this.handleMessage(message);
}
}
catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
debug('Protocol error: %s', err.message);
this.emit('error', new types_1.ProtocolError(err.message));
this.disconnect();
}
});
this.socket.on('error', (error) => {
debug('Socket error: %s', error.message);
this.emit('error', error);
});
this.socket.on('close', () => {
debug('Socket closed');
this.handleDisconnect();
});
this.socket.on('end', () => {
debug('Socket ended');
this.handleDisconnect();
});
}
/**
* Handle incoming messages
*/
handleMessage(message) {
// Handle ping/pong
if (message.type === types_1.MessageType.PingRequest) {
this.sendMessage(types_1.MessageType.PingResponse, Buffer.alloc(0));
return;
}
if (message.type === types_1.MessageType.PingResponse) {
this.handlePongResponse();
return;
}
// Handle disconnect request (device going to sleep)
if (message.type === types_1.MessageType.DisconnectRequest) {
this.handleDisconnectRequest();
return;
}
// Emit message for higher-level handling
this.emit('message', message);
}
/**
* Send a message to the device
*/
sendMessage(type, data) {
if (!this.socket || !this.state.connected) {
throw new types_1.ConnectionError('Not connected');
}
if (this.encryptor && this.encryptionEstablished) {
// For encrypted connections, use a different message format:
// 2 bytes: message type (big-endian)
// 2 bytes: message length (big-endian)
// N bytes: protobuf data
// (No preamble byte, no varint encoding)
const messageType = Buffer.allocUnsafe(2);
messageType.writeUInt16BE(type, 0);
const messageLength = Buffer.allocUnsafe(2);
messageLength.writeUInt16BE(data.length, 0);
const plaintext = Buffer.concat([messageType, messageLength, data]);
// Encrypt the plaintext
const encryptedFrame = Buffer.from(this.encryptor.EncryptWithAd([], new Uint8Array(plaintext)));
const dataFrame = this.createDataFrame(encryptedFrame);
this.socket.write(dataFrame);
debug('Sent encrypted message type %d, plaintext %d bytes, encrypted %d bytes, frame %d bytes', type, data.length, encryptedFrame.length, dataFrame.length);
}
else {
// Send unencrypted using standard protocol format
const frame = this.protocol.encodeMessage(type, data);
this.socket.write(frame);
debug('Sent message type %d, %d bytes', type, data.length);
}
}
/**
* Handle disconnect request from device (deep sleep)
*/
handleDisconnectRequest() {
debug('Received DisconnectRequest - device going to sleep');
try {
// Send acknowledgment
this.sendMessage(types_1.MessageType.DisconnectResponse, Buffer.alloc(0));
debug('Sent DisconnectResponse');
}
catch (error) {
debug('Failed to send DisconnectResponse: %s', error);
}
// Mark as expected disconnect
this.expectedDisconnect = true;
// Stop ping mechanism - device is sleeping
this.stopPingTimer();
this.stopPingTimeoutTimer();
// If device has deep sleep, disable auto-reconnect
if (this.hasDeepSleep) {
debug('Deep sleep device - disabling auto-reconnect');
this.options.reconnect = false;
}
// Disconnect cleanly
this.disconnect();
}
/**
* Disconnect from the device
*/
disconnect() {
debug('Disconnecting (expected: %s)', this.expectedDisconnect);
this.cleanup();
this.updateState({ connected: false, authenticated: false });
this.emit('disconnect');
// Reset expected disconnect flag after handling
this.expectedDisconnect = false;
}
/**
* Handle disconnection and potential reconnection
*/
handleDisconnect() {
const wasConnected = this.state.connected;
this.cleanup();
this.updateState({ connected: false, authenticated: false });
this.encryptionEstablished = false;
if (wasConnected) {
this.emit('disconnect');
}
if (this.options.reconnect && !this.isDestroyed && !this.isReconnecting) {
this.scheduleReconnect();
}
}
/**
* Schedule a reconnection attempt
*/
scheduleReconnect() {
if (this.isReconnecting || this.isDestroyed)
return;
debug('Scheduling reconnect in %dms', this.options.reconnectInterval);
this.isReconnecting = true;
this.reconnectTimer = setTimeout(async () => {
if (this.isDestroyed)
return;
debug('Attempting to reconnect');
try {
await this.connect();
this.isReconnecting = false;
}
catch (error) {
debug('Reconnection failed: %s', error);
this.isReconnecting = false;
this.scheduleReconnect();
}
}, this.options.reconnectInterval);
}
/**
* Enable deep sleep mode for this connection
*/
setDeepSleepMode(enabled) {
this.hasDeepSleep = enabled;
debug('Deep sleep mode %s', enabled ? 'enabled' : 'disabled');
if (enabled && this.state.connected) {
// If already connected and deep sleep is enabled, stop pinging
this.stopPingTimer();
debug('Stopped ping timer for deep sleep device');
}
}
/**
* Check if this is an expected disconnect (e.g., deep sleep)
*/
isExpectedDisconnect() {
return this.expectedDisconnect;
}
/**
* Start the ping timer
*/
startPingTimer() {
// Don't ping deep sleep devices
if (this.hasDeepSleep) {
debug('Deep sleep device - ping disabled');
return;
}
this.stopPingTimer();
this.pingTimer = setInterval(() => {
if (!this.state.connected) {
this.stopPingTimer();
return;
}
debug('Sending ping');
try {
this.sendMessage(types_1.MessageType.PingRequest, Buffer.alloc(0));
this.startPingTimeoutTimer();
}
catch (error) {
debug('Failed to send ping: %s', error);
this.handleDisconnect();
}
}, this.options.pingInterval);
}
/**
* Start the ping timeout timer
*/
startPingTimeoutTimer() {
this.stopPingTimeoutTimer();
this.pingTimeoutTimer = setTimeout(() => {
debug('Ping timeout');
this.handleDisconnect();
}, this.options.pingTimeout);
this.pingTimeoutTimer.unref();
}
/**
* Handle pong response
*/
handlePongResponse() {
debug('Received pong');
this.stopPingTimeoutTimer();
}
/**
* Stop the ping timer
*/
stopPingTimer() {
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = undefined;
}
this.stopPingTimeoutTimer();
}
/**
* Stop the ping timeout timer
*/
stopPingTimeoutTimer() {
if (this.pingTimeoutTimer) {
clearTimeout(this.pingTimeoutTimer);
this.pingTimeoutTimer = undefined;
}
}
/**
* Clean up resources
*/
cleanup() {
this.stopPingTimer();
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = undefined;
}
if (this.socket) {
this.socket.removeAllListeners();
this.socket.destroy();
this.socket = null;
}
this.protocol.clearBuffer();
this.encryptionBuffer = Buffer.alloc(0);
if (this.noise) {
this.noise.reset();
}
}
/**
* Update connection state
*/
updateState(newState) {
const oldState = { ...this.state };
this.state = { ...this.state, ...newState };
if (JSON.stringify(oldState) !== JSON.stringify(this.state)) {
debug('State changed: %o', this.state);
this.emit('stateChange', this.state);
}
}
/**
* Destroy the connection
*/
destroy() {
debug('Destroying connection');
this.isDestroyed = true;
this.cleanup();
this.removeAllListeners();
}
/**
* Get the current connection state
*/
getState() {
return { ...this.state };
}
/**
* Check if connected
*/
isConnected() {
return this.state.connected;
}
/**
* Check if authenticated
*/
isAuthenticated() {
return this.state.authenticated;
}
/**
* Check if encryption is established
*/
isEncrypted() {
return this.encryptionEstablished;
}
/**
* Set authentication state
*/
setAuthenticated(authenticated) {
this.updateState({ authenticated });
}
/**
* Set API version
*/
setApiVersion(major, minor) {
this.updateState({ apiVersion: { major, minor } });
}
/**
* Set server info
*/
setServerInfo(info) {
this.updateState({ serverInfo: info });
}
}
exports.EncryptedConnection = EncryptedConnection;
//# sourceMappingURL=encrypted-connection-broken.js.map