UNPKG

@webarray/esphome-native-api

Version:

TypeScript/Node.js client for ESPHome native API with encryption and deep sleep support

587 lines 24.4 kB
"use strict"; /** * Encrypted Connection Handler for ESPHome Native API * Based on the official Python aioesphomeapi implementation * Uses Noise Protocol Framework (Noise_NNpsk0_25519_ChaChaPoly_SHA256) */ 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 0x01 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 || 90000, // Match Python's 4.5x ratio (90s) - CRITICAL FIX connectTimeout: options.connectTimeout || 10000, encryptionKey: options.encryptionKey || '', expectedServerName: options.expectedServerName || '', respondToTimeRequests: options.respondToTimeRequests !== false, // Default to true logger: options.logger, timerFactory: options.timerFactory, }; 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: (context) => { debug('Connection attempt %d failed: %s', context.attemptNumber, context.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 based on ESPHome protocol spec * Protocol: Noise_NNpsk0_25519_ChaChaPoly_SHA256 * Reference: https://developers.esphome.io/architecture/api/protocol_details/ */ async performNoiseHandshake() { if (!this.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) => { createNoise((noise) => { debug('Noise library loaded for handshake'); 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'); } 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 server hello, 1 = waiting for handshake response const handleHandshakeData = async (data) => { debug('Handshake data received, length=%d, step=%d', data.length, handshakeStep); try { this.encryptionBuffer = Buffer.concat([this.encryptionBuffer, data]); while (this.encryptionBuffer.length >= 3) { const frameType = this.encryptionBuffer[0]; const frameLength = this.encryptionBuffer.readUInt16BE(1); if (this.encryptionBuffer.length < 3 + frameLength) { return; // Not enough data yet } if (frameType !== FRAME_TYPE) { throw new Error(`Expected frame type 0x01, got 0x${frameType.toString(16)}`); } const frameData = this.encryptionBuffer.slice(3, 3 + frameLength); this.encryptionBuffer = this.encryptionBuffer.slice(3 + frameLength); // Handle encrypted messages after handshake is complete if (this.encryptionEstablished && this.decryptor) { 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); if (decryptedData.length < 4) { debug('Decrypted data too short'); return; } // Message format: [2 bytes type][2 bytes length][protobuf data] 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 handshake message const handshakeMsg = client.WriteMessage(); 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()}`); } // Complete handshake client.ReadMessage(new Uint8Array(message), true); // Split into send and receive ciphers [encryptor, decryptor] = client.Split(); this.encryptor = encryptor; this.decryptor = decryptor; this.encryptionEstablished = true; clearTimeout(timeout); debug('Encryption established successfully'); 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'); // Send initial hello (empty frame) const helloFrame = this.createHandshakeFrame(Buffer.alloc(0)); socket.write(helloFrame); debug('Sent hello frame'); socket.on('error', (err) => { debug('Socket error during handshake: %s', err.message); }); } }); }); } /** * 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; frame.writeUInt16BE(data.length, 1); data.copy(frame, 3); return frame; } /** * Setup socket event handlers */ 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); } /** * Handle disconnect request from device (deep sleep) */ handleDisconnectRequest() { debug('Received DisconnectRequest - device going to sleep'); try { this.sendMessage(types_1.MessageType.DisconnectResponse, Buffer.alloc(0)); debug('Sent DisconnectResponse'); } catch (error) { debug('Failed to send DisconnectResponse: %s', error); } this.expectedDisconnect = true; this.stopPingTimer(); this.stopPingTimeoutTimer(); if (this.hasDeepSleep) { debug('Deep sleep device - disabling auto-reconnect'); this.options.reconnect = false; } this.disconnect(); } disconnect() { debug('Disconnecting'); this.cleanup(); this.updateState({ connected: false, authenticated: false }); this.emit('disconnect'); } /** * 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) { // Encrypted message format: [2 bytes type][2 bytes length][protobuf data] 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', type, data.length, encryptedFrame.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); } } getState() { return { ...this.state }; } isConnected() { return this.state.connected; } isAuthenticated() { return this.state.authenticated; } isEncrypted() { return !!this.options.encryptionKey; } setAuthenticated(authenticated) { this.updateState({ authenticated }); } setApiVersion(major, minor) { this.updateState({ apiVersion: { major, minor } }); } setServerInfo(info) { this.updateState({ serverInfo: info }); } destroy() { debug('Destroying connection'); this.isDestroyed = true; this.cleanup(); this.removeAllListeners(); } /** * 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) { this.stopPingTimer(); debug('Stopped ping timer for deep sleep device'); } } /** * Check if this is an expected disconnect */ isExpectedDisconnect() { return this.expectedDisconnect; } /** * Start the ping timer */ startPingTimer() { 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(); } } updateState(newState) { const oldState = { ...this.state }; this.state = { ...this.state, ...newState }; if (JSON.stringify(oldState) !== JSON.stringify(this.state)) { this.emit('stateChange', this.state); } } } exports.EncryptedConnection = EncryptedConnection; //# sourceMappingURL=encrypted-connection.js.map