UNPKG

@webarray/esphome-native-api

Version:

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

877 lines 33.6 kB
"use strict"; /** * ESPHome Native API Client * High-level client for interacting with ESPHome devices */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ESPHomeClient = void 0; const eventemitter3_1 = require("eventemitter3"); const debug_1 = __importDefault(require("debug")); const encrypted_connection_1 = require("../connection/encrypted-connection"); const api = __importStar(require("../proto/api")); const types_1 = require("../types"); const debug = (0, debug_1.default)('esphome:client'); class ESPHomeClient extends eventemitter3_1.EventEmitter { constructor(options) { super(); this.entities = new Map(); this.stateSubscriptions = new Set(); this.logSubscriptions = new Set(); this.isAuthenticating = false; this.options = options; // TEMPORARY: Always use EncryptedConnection (works for both encrypted and unencrypted) // There's a bug in the Connection class that causes device to close connection on ListEntitiesRequest debug(options.encryptionKey ? 'Using encrypted connection' : 'Using unencrypted connection (via EncryptedConnection)'); this.connection = new encrypted_connection_1.EncryptedConnection(options); this.setupConnectionHandlers(); debug('Client initialized for %s:%d', options.host, options.port || 6053); } /** * Setup connection event handlers */ setupConnectionHandlers() { const conn = this.connection; // Type workaround for union type conn.on('connect', () => { debug('Connection established, starting handshake'); this.performHandshake().catch((error) => { debug('Handshake failed: %s', error); this.connection.disconnect(); }); }); conn.on('disconnect', (error) => { debug('Disconnected: %s', error?.message || 'No error'); this.emit('disconnected', error); }); conn.on('message', (message) => { this.handleMessage(message).catch((error) => { debug('Message handling error: %s', error); this.emit('error', error instanceof Error ? error : new Error(String(error))); }); }); conn.on('error', (error) => { this.emit('error', error); }); } /** * Connect to the ESPHome device */ async connect() { debug('Connecting to device'); await this.connection.connect(); // Wait for the handshake to complete // The handshake is performed asynchronously after the connection is established await new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.off('connected', handleConnected); this.off('error', handleError); reject(new Error('Handshake timeout')); }, 10000); // Allow Node.js to exit if this is the only timer left timeout.unref(); const handleConnected = () => { clearTimeout(timeout); this.off('error', handleError); resolve(); }; const handleError = (error) => { clearTimeout(timeout); this.off('connected', handleConnected); reject(error); }; this.once('connected', handleConnected); this.once('error', handleError); }); } /** * Disconnect from the device */ disconnect() { debug('Disconnecting from device'); this.connection.disconnect(); } /** * Perform the initial handshake */ async performHandshake() { // Send Hello request await this.sendHelloRequest(); // Wait for Hello response const helloResponse = await this.waitForMessage(types_1.MessageType.HelloResponse, 5000); this.handleHelloResponse(helloResponse); // Always send authentication (like Python's login=True) // For devices without password: they may not respond, which is fine // For devices with password: they will respond with AuthenticationResponse await this.authenticate(); // Mark as authenticated this.connection.setAuthenticated(true); // Request device info await this.requestDeviceInfo(); // Emit connected event this.emit('connected'); } /** * Send Hello request */ async sendHelloRequest() { const message = { clientInfo: this.options.clientInfo || 'ESPHome TypeScript Client', apiVersionMajor: 1, apiVersionMinor: 13, // Match Python aioesphomeapi client version }; const data = this.encodeMessage('HelloRequest', message); this.connection.sendMessage(types_1.MessageType.HelloRequest, data); debug('Sent Hello request'); } /** * Handle Hello response */ handleHelloResponse(message) { const response = this.decodeMessage('HelloResponse', message.data); debug('Hello response: %o', response); this.connection.setApiVersion(response.apiVersionMajor || 1, response.apiVersionMinor || 9); if (response.serverInfo) { this.connection.setServerInfo(response.serverInfo); } } /** * Authenticate with the device */ async authenticate() { if (this.isAuthenticating) { throw new types_1.AuthenticationError('Already authenticating'); } this.isAuthenticating = true; try { debug('Sending authentication request%s', this.options.password ? ' with password' : ''); const message = { password: this.options.password || '', }; const data = this.encodeMessage('AuthenticationRequest', message); this.connection.sendMessage(types_1.MessageType.ConnectRequest, data); // Only wait for response if password is set // Devices without password authentication won't send AuthenticationResponse if (this.options.password) { const response = await this.waitForMessage(types_1.MessageType.ConnectResponse, 5000); const connectResponse = this.decodeMessage('AuthenticationResponse', response.data); if (connectResponse.invalidPassword) { throw new types_1.AuthenticationError('Invalid password'); } debug('Authentication successful'); } else { debug('Authentication request sent (no response expected without password)'); } } finally { this.isAuthenticating = false; } } /** * Request device information */ async requestDeviceInfo() { debug('Requesting device info'); this.connection.sendMessage(types_1.MessageType.DeviceInfoRequest, Buffer.alloc(0)); const response = await this.waitForMessage(types_1.MessageType.DeviceInfoResponse, 5000); const info = this.decodeMessage('DeviceInfoResponse', response.data); this.deviceInfo = { usesPassword: info.uses_password || false, name: info.name || '', macAddress: info.mac_address || '', esphomeVersion: info.esphome_version || '', compilationTime: info.compilation_time || '', model: info.model || '', hasDeepSleep: info.has_deep_sleep || false, projectName: info.project_name, projectVersion: info.project_version, webserverPort: info.webserver_port, bluetoothProxyVersion: info.bluetooth_proxy_version, manufacturer: info.manufacturer, friendlyName: info.friendly_name, voiceAssistantVersion: info.voice_assistant_version, suggestedArea: info.suggested_area, }; debug('Device info: %o', this.deviceInfo); // Enable deep sleep mode on connection if device supports it if (this.deviceInfo.hasDeepSleep) { debug('Device has deep sleep - configuring connection'); if ('setDeepSleepMode' in this.connection) { this.connection.setDeepSleepMode(true); } } this.emit('deviceInfo', this.deviceInfo); } /** * Get device information */ getDeviceInfo() { return this.deviceInfo; } /** * List all entities on the device */ async listEntities() { debug('Listing entities'); this.entities.clear(); this.connection.sendMessage(types_1.MessageType.ListEntitiesRequest, Buffer.alloc(0)); // Wait for ListEntitiesDone message await this.waitForMessage(types_1.MessageType.ListEntitiesDoneResponse, 10000); return Array.from(this.entities.values()); } /** * Subscribe to state changes */ subscribeStates(callback) { debug('Subscribing to states'); if (callback) { this.stateSubscriptions.add(callback); } this.connection.sendMessage(types_1.MessageType.SubscribeStatesRequest, Buffer.alloc(0)); } /** * Unsubscribe from state changes */ unsubscribeStates(callback) { if (callback) { this.stateSubscriptions.delete(callback); } } /** * Subscribe to logs */ subscribeLogs(level = 3, callback) { debug('Subscribing to logs with level %d', level); if (callback) { this.logSubscriptions.add(callback); } const message = { level, dumpConfig: false, }; const data = this.encodeMessage('SubscribeLogsRequest', message); this.connection.sendMessage(types_1.MessageType.SubscribeLogsRequest, data); } /** * Unsubscribe from logs */ unsubscribeLogs(callback) { if (callback) { this.logSubscriptions.delete(callback); } } /** * Get all entities of a specific type * @param type - Entity type filter (e.g., 'sensor', 'binary_sensor', 'switch') * @returns Array of entities matching the type */ getEntitiesByType(type) { const entities = Array.from(this.entities.values()); return entities.filter((entity) => { // Prefer explicit type field when available if (entity.type) { if (typeof type === 'string') { return entity.type === type; } return entity.type === type; } // Fallback: match by constructor name or inferred type const constructorName = entity.constructor?.name?.toLowerCase() || ''; return constructorName.includes(String(type).toLowerCase().replace('_', '')); }); } /** * Find entity by name or object ID * @param nameOrId - Entity name or object ID to search for * @returns First matching entity or undefined */ findEntity(nameOrId) { const entities = Array.from(this.entities.values()); const search = nameOrId.toLowerCase(); return entities.find((entity) => entity.name.toLowerCase().includes(search) || entity.objectId.toLowerCase().includes(search) || entity.uniqueId?.toLowerCase().includes(search)); } /** * Find all entities matching a search term * @param searchTerm - Search term to match against name, object ID, or unique ID * @returns Array of matching entities */ findEntities(searchTerm) { const entities = Array.from(this.entities.values()); const search = searchTerm.toLowerCase(); return entities.filter((entity) => entity.name.toLowerCase().includes(search) || entity.objectId.toLowerCase().includes(search) || entity.uniqueId?.toLowerCase().includes(search)); } /** * Get entity by key * @param key - Entity key * @returns Entity info or undefined if not found */ getEntityByKey(key) { return this.entities.get(key); } /** * Get entity state by key (requires state subscription to be active) * Note: This returns the last known state. Subscribe to states to receive updates. * @param key - Entity key * @returns Entity info for the key, or undefined if not found */ getEntityInfo(key) { return this.entities.get(key); } /** * Wait for an entity to appear during discovery * @param nameOrId - Entity name or object ID to wait for * @param timeout - Timeout in milliseconds (default: 30000) * @returns Promise that resolves with the entity info */ async waitForEntity(nameOrId, timeout = 30000) { const search = nameOrId.toLowerCase(); // Check if entity already exists const existing = this.findEntity(search); if (existing) { return existing; } // Wait for entity to appear return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.off('entity', handler); reject(new types_1.ESPHomeError(`Timeout waiting for entity: ${nameOrId}`, 'TIMEOUT', 'Ensure the entity exists on the device and listEntities() has been called', { nameOrId, timeout })); }, timeout); const handler = (entity) => { const matches = entity.name.toLowerCase().includes(search) || entity.objectId.toLowerCase().includes(search) || entity.uniqueId?.toLowerCase().includes(search); if (matches) { clearTimeout(timer); this.off('entity', handler); resolve(entity); } }; this.on('entity', handler); }); } /** * Get all entities * @returns Array of all entities */ getAllEntities() { return Array.from(this.entities.values()); } /** * Check if an entity exists * @param key - Entity key or name * @returns True if entity exists */ hasEntity(key) { if (typeof key === 'number') { return this.entities.has(key); } return this.findEntity(key) !== undefined; } /** * Get entity count * @returns Total number of entities */ getEntityCount() { return this.entities.size; } /** * Get entities by category * @param category - Entity category (EntityCategory.NONE, EntityCategory.CONFIG, or EntityCategory.DIAGNOSTIC) * @returns Array of entities in the specified category */ getEntitiesByCategory(category) { const entities = Array.from(this.entities.values()); return entities.filter((entity) => entity.entityCategory === category); } /** * Switch command */ async switchCommand(key, state) { debug('Switch command: key=%d, state=%s', key, state); const message = { key, state, }; const data = this.encodeMessage('SwitchCommandRequest', message); this.connection.sendMessage(types_1.MessageType.SwitchCommandRequest, data); } /** * Light command */ async lightCommand(key, options) { debug('Light command: key=%d, options=%o', key, options); const message = { key, ...options, }; const data = this.encodeMessage('LightCommandRequest', message); this.connection.sendMessage(types_1.MessageType.LightCommandRequest, data); } /** * Fan command */ async fanCommand(key, options) { debug('Fan command: key=%d, options=%o', key, options); const message = { key, }; if (options.state !== undefined) { message.state = options.state; message.has_state = true; } if (options.speed !== undefined) { message.speed = options.speed; message.has_speed = true; } if (options.speedLevel !== undefined) { message.speed_level = options.speedLevel; message.has_speed_level = true; } if (options.oscillating !== undefined) { message.oscillating = options.oscillating; message.has_oscillating = true; } const data = this.encodeMessage('FanCommandRequest', message); this.connection.sendMessage(types_1.MessageType.FanCommandRequest, data); } async numberCommand(key, state) { debug('Number command: key=%d, state=%d', key, state); const message = { key, state, }; const data = this.encodeMessage('NumberCommandRequest', message); this.connection.sendMessage(types_1.MessageType.NumberCommandRequest, data); } async selectCommand(key, state) { debug('Select command: key=%d, state=%s', key, state); const message = { key, state, }; const data = this.encodeMessage('SelectCommandRequest', message); this.connection.sendMessage(types_1.MessageType.SelectCommandRequest, data); } async buttonCommand(key) { debug('Button command: key=%d', key); const message = { key, }; const data = this.encodeMessage('ButtonCommandRequest', message); this.connection.sendMessage(types_1.MessageType.ButtonCommandRequest, data); } /** * Handle incoming messages */ async handleMessage(message) { switch (message.type) { case types_1.MessageType.DeviceInfoResponse: this.handleDeviceInfoResponse(message); break; case types_1.MessageType.ListEntitiesBinarySensorResponse: case types_1.MessageType.ListEntitiesSensorResponse: case types_1.MessageType.ListEntitiesSwitchResponse: case types_1.MessageType.ListEntitiesLightResponse: case types_1.MessageType.ListEntitiesTextSensorResponse: case types_1.MessageType.ListEntitiesFanResponse: case types_1.MessageType.ListEntitiesCoverResponse: case types_1.MessageType.ListEntitiesNumberResponse: case types_1.MessageType.ListEntitiesSelectResponse: case types_1.MessageType.ListEntitiesButtonResponse: this.handleEntityResponse(message); break; case types_1.MessageType.BinarySensorStateResponse: case types_1.MessageType.SensorStateResponse: case types_1.MessageType.SwitchStateResponse: case types_1.MessageType.TextSensorStateResponse: case types_1.MessageType.LightStateResponse: case types_1.MessageType.FanStateResponse: case types_1.MessageType.CoverStateResponse: case types_1.MessageType.NumberStateResponse: case types_1.MessageType.SelectStateResponse: this.handleStateResponse(message); break; case types_1.MessageType.SubscribeLogsResponse: this.handleLogResponse(message); break; case types_1.MessageType.ListEntitiesDoneResponse: // This message signals the end of the entity list // waitForMessage() will handle it debug('Received ListEntitiesDoneResponse - entity list complete'); break; case types_1.MessageType.GetTimeRequest: // Device is requesting current time - respond if enabled if (this.options.respondToTimeRequests !== false) { this.handleGetTimeRequest(); } else { debug('Received GetTimeRequest (ignoring - respondToTimeRequests disabled)'); } break; default: debug('Unhandled message type: %d', message.type); } } /** * Handle GetTimeRequest from device */ handleGetTimeRequest() { debug('Received GetTimeRequest from device'); // Send current Unix timestamp (seconds since epoch) const epochSeconds = Math.floor(Date.now() / 1000); const message = { epochSeconds, }; const data = this.encodeMessage('GetTimeResponse', message); this.connection.sendMessage(types_1.MessageType.GetTimeResponse, data); debug('Sent GetTimeResponse with timestamp %d', epochSeconds); } /** * Handle device info response */ handleDeviceInfoResponse(message) { const info = this.decodeMessage('DeviceInfoResponse', message.data); debug('Device info response: %o', info); } /** * Handle entity response */ handleEntityResponse(message) { let entity = null; let entityType = null; switch (message.type) { case types_1.MessageType.ListEntitiesBinarySensorResponse: entity = this.decodeMessage('ListEntitiesBinarySensorResponse', message.data); entityType = types_1.EntityDomain.BINARY_SENSOR; break; case types_1.MessageType.ListEntitiesSensorResponse: entity = this.decodeMessage('ListEntitiesSensorResponse', message.data); entityType = types_1.EntityDomain.SENSOR; break; case types_1.MessageType.ListEntitiesSwitchResponse: entity = this.decodeMessage('ListEntitiesSwitchResponse', message.data); entityType = types_1.EntityDomain.SWITCH; break; case types_1.MessageType.ListEntitiesLightResponse: entity = this.decodeMessage('ListEntitiesLightResponse', message.data); entityType = types_1.EntityDomain.LIGHT; break; case types_1.MessageType.ListEntitiesTextSensorResponse: entity = this.decodeMessage('ListEntitiesTextSensorResponse', message.data); entityType = types_1.EntityDomain.TEXT_SENSOR; break; case types_1.MessageType.ListEntitiesFanResponse: entity = this.decodeMessage('ListEntitiesFanResponse', message.data); entityType = types_1.EntityDomain.FAN; break; case types_1.MessageType.ListEntitiesCoverResponse: entity = this.decodeMessage('ListEntitiesCoverResponse', message.data); entityType = types_1.EntityDomain.COVER; break; case types_1.MessageType.ListEntitiesNumberResponse: entity = this.decodeMessage('ListEntitiesNumberResponse', message.data); entityType = types_1.EntityDomain.NUMBER; break; case types_1.MessageType.ListEntitiesSelectResponse: entity = this.decodeMessage('ListEntitiesSelectResponse', message.data); entityType = types_1.EntityDomain.SELECT; break; case types_1.MessageType.ListEntitiesButtonResponse: entity = this.decodeMessage('ListEntitiesButtonResponse', message.data); entityType = types_1.EntityDomain.BUTTON; break; } if (entity && entity.key !== undefined) { const entityWithType = { ...entity, type: entity.type ?? entityType, }; this.entities.set(entityWithType.key, entityWithType); this.emit('entity', entityWithType); debug('Entity registered: %o', entityWithType); } } /** * Handle state response */ handleStateResponse(message) { let state = null; switch (message.type) { case types_1.MessageType.BinarySensorStateResponse: state = this.decodeMessage('BinarySensorStateResponse', message.data); this.emit('binarySensorState', state); break; case types_1.MessageType.SensorStateResponse: state = this.decodeMessage('SensorStateResponse', message.data); this.emit('sensorState', state); break; case types_1.MessageType.SwitchStateResponse: state = this.decodeMessage('SwitchStateResponse', message.data); this.emit('switchState', state); break; case types_1.MessageType.TextSensorStateResponse: state = this.decodeMessage('TextSensorStateResponse', message.data); this.emit('textSensorState', state); break; case types_1.MessageType.FanStateResponse: state = this.decodeMessage('FanStateResponse', message.data); this.emit('fanState', state); break; case types_1.MessageType.CoverStateResponse: state = this.decodeMessage('CoverStateResponse', message.data); this.emit('coverState', state); break; case types_1.MessageType.LightStateResponse: state = this.decodeMessage('LightStateResponse', message.data); this.emit('lightState', state); break; case types_1.MessageType.NumberStateResponse: state = this.decodeMessage('NumberStateResponse', message.data); this.emit('numberState', state); break; case types_1.MessageType.SelectStateResponse: state = this.decodeMessage('SelectStateResponse', message.data); this.emit('selectState', state); break; } if (state) { this.emit('state', state); // Notify subscriptions for (const callback of this.stateSubscriptions) { try { callback(state); } catch (error) { debug('State subscription callback error: %s', error); } } debug('State update: %o', state); } } /** * Handle log response */ handleLogResponse(message) { const log = this.decodeMessage('SubscribeLogsResponse', message.data); const entry = { level: log.level || 0, message: log.message || '', sendFailed: log.sendFailed || false, }; this.emit('logs', entry); // Notify subscriptions for (const callback of this.logSubscriptions) { try { callback(entry); } catch (error) { debug('Log subscription callback error: %s', error); } } debug('Log: [%d] %s', entry.level, entry.message); } /** * Wait for a specific message type */ waitForMessage(type, timeout) { return new Promise((resolve, reject) => { const conn = this.connection; // Type workaround const timer = setTimeout(() => { conn.off('message', handler); reject(new types_1.ESPHomeError(`Timeout waiting for message type ${type}`)); }, timeout); // Allow Node.js to exit if this is the only timer left timer.unref(); const handler = (message) => { if (message.type === type) { clearTimeout(timer); conn.off('message', handler); resolve(message); } }; conn.on('message', handler); }); } /** * Encode a message using protobuf */ encodeMessage(type, data) { try { const MessageClass = api[type]; if (!MessageClass || !MessageClass.encode) { debug('Unknown message type: %s', type); return Buffer.alloc(0); } const message = MessageClass.create(data); const encoded = MessageClass.encode(message).finish(); return Buffer.from(encoded); } catch (error) { debug('Failed to encode message type %s: %s', type, error); return Buffer.alloc(0); } } /** * Decode a message using protobuf */ decodeMessage(type, data) { try { const MessageClass = api[type]; if (!MessageClass || !MessageClass.decode) { debug('Unknown message type: %s', type); return {}; } const decoded = MessageClass.decode(data); return decoded; } catch (error) { debug('Failed to decode message type %s: %s', type, error); return {}; } } /** * Destroy the client */ destroy() { debug('Destroying client'); this.connection.destroy(); this.entities.clear(); this.stateSubscriptions.clear(); this.logSubscriptions.clear(); this.removeAllListeners(); } /** * Check if connected */ isConnected() { return this.connection.isConnected(); } /** * Check if authenticated */ isAuthenticated() { return this.connection.isAuthenticated(); } /** * Get connection health metrics * @returns Health metrics including latency, uptime, and message counts */ getHealthMetrics() { if ('getHealthMetrics' in this.connection) { return this.connection.getHealthMetrics(); } return undefined; } /** * Get connection health status with analysis * @returns Health status with metrics, status indicator, and identified issues */ getConnectionHealth() { if ('getConnectionHealth' in this.connection) { return this.connection.getConnectionHealth(); } return undefined; } /** * Reset health metrics counters */ resetHealthMetrics() { if ('resetHealthMetrics' in this.connection) { this.connection.resetHealthMetrics(); } } /** * Enable detailed logging * Sets DEBUG environment variable for this instance */ enableDetailedLogging() { // Store original debug value if (typeof process !== 'undefined' && process.env) { process.env['DEBUG'] = 'esphome:*'; debug('Detailed logging enabled'); } else { debug('Detailed logging could not be enabled'); } } /** * Get connection metrics for debugging * @returns Object containing connection statistics */ getConnectionMetrics() { const health = this.getHealthMetrics(); const state = this.connection.getState(); return { state, health, entityCount: this.entities.size, subscriptions: { states: this.stateSubscriptions.size, logs: this.logSubscriptions.size, }, }; } /** * Capture protocol dump to console * Useful for debugging protocol issues * @param enable - Enable or disable protocol dumping */ captureProtocolDump(enable = true) { if (enable) { debug('Protocol dump enabled - all messages will be logged'); // Hook into message events const conn = this.connection; conn.on('message', (msg) => { console.log('PROTOCOL DUMP:', { type: msg.type, data: msg.data, timestamp: Date.now(), }); }); } else { debug('Protocol dump disabled'); } } } exports.ESPHomeClient = ESPHomeClient; //# sourceMappingURL=client.js.map