UNPKG

homebridge-smartsystem

Version:

SmartServer (Proxy TCP sockets to the cloud, Smappee MQTT, Duotecno IP Nodes, Homekit interface)

463 lines 21.6 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Context = exports.cleanStart = exports.makeNewCloudConnection = exports.gCloudConnections = exports.setProxyConfig = exports.setWebApp = exports.kEmptyProxy = void 0; const net = require("net"); const child_process_1 = require("child_process"); const process_1 = require("process"); exports.kEmptyProxy = { cloudServer: "masters.duotecno.eu", cloudPort: 5097, configPort: 5099, masterAddress: "", masterPort: 5001, masterConfigPort: 8080, uniqueId: "", configUniqueId: "", debug: false, kind: "gw" // default running under the gateway or other process manager -> don't restart yourself }; const config = Object.assign({}, exports.kEmptyProxy); // Store reference to the webapp so we can close servers before restarting let gWebApp = null; function setWebApp(webapp) { gWebApp = webapp; } exports.setWebApp = setWebApp; function setProxyConfig(c) { // set the proxy config if (c) { config.cloudServer = c.cloudServer || exports.kEmptyProxy.cloudServer; config.cloudPort = c.cloudPort || exports.kEmptyProxy.cloudPort; config.configPort = c.configPort || exports.kEmptyProxy.configPort; config.masterAddress = c.masterAddress || exports.kEmptyProxy.masterAddress; config.masterPort = c.masterPort || exports.kEmptyProxy.masterPort; config.masterConfigPort = c.masterConfigPort || exports.kEmptyProxy.masterConfigPort; config.uniqueId = c.uniqueId || exports.kEmptyProxy.uniqueId; config.configUniqueId = c.configUniqueId || exports.kEmptyProxy.configUniqueId; config.debug = !!c.debug; config.kind = c.kind || exports.kEmptyProxy.kind; } return config; } exports.setProxyConfig = setProxyConfig; let connectionChecker = null; exports.gCloudConnections = []; const kDebug = config.debug || false; // enable debug mode from config ///////////// // Logging // ///////////// function debug(str) { if (kDebug) { console.log(`DEBUG: ${str}`); } } function warning(str) { console.warn(`WARNING: ${str}`); } function log(str) { console.log(`LOG: ${str}`); } function error(str) { console.error(`ERROR: **** ${str} ****`); } /////////////////////////////// // Start up the proxy server // /////////////////////////////// // Only auto-start if we have a config-proxy.json file (standalone mode) // When running under Platform/HomeBridge, let Platform control the startup try { setProxyConfig(require("/../config-proxy.json")); // config-proxy.json exists, we're running standalone -> start the proxy log("[PROXY] Running in standalone mode (config-proxy.json found), starting proxy..."); cleanStart(true); } catch (e) { // No config-proxy.json, we're probably running under Platform/HomeBridge setProxyConfig(); log("[PROXY] No config-proxy.json found, waiting for Platform to start proxy..."); } function makeNewCloudConnection(master, masterPort, server, serverPort, uniqueId, count = 1) { // retry making a cloud connection function reconnect(err) { error(`[PROXY] → [CLOUD] Failed to connect ${count} times to the Cloud server: ${err.code || err.message}`); if (count < 3) { setTimeout(() => { makeNewCloudConnection(master, masterPort, server, serverPort, uniqueId, count + 1); }, 1000 * count * count); // exponential backoff: 1 second, 4 seconds, 9 seconds -> give up // we check every 16 seconds for a free connection, if none found -> restart } } const cloudSocket = new net.Socket(); cloudSocket.once('error', reconnect); log(`[PROXY] → [CLOUD] Attempt ${count} to start a new free connection for ${config.uniqueId} to the Cloud at ${server}:${serverPort}`); cloudSocket.connect(serverPort, server, () => { log(`[PROXY] → [CLOUD] Connected to Cloud at ${server}:${serverPort}`); cloudSocket.removeListener('error', reconnect); // Send unique ID as the first message to identify this proxy/device cloudSocket.write(`[${uniqueId}]`); debug(`[PROXY] → [CLOUD] Sent unique ID: ${uniqueId}`); const context = new Context(cloudSocket, master, masterPort, server, serverPort, uniqueId); exports.gCloudConnections.push(context); log(`[PROXY] → [CLOUD] New free connection #${exports.gCloudConnections.length} to the Cloud at ${server}:${serverPort}`); }); } exports.makeNewCloudConnection = makeNewCloudConnection; //////////////// // Restarting // //////////////// function cleanStart(restart = false) { if (exports.gCloudConnections === null || exports.gCloudConnections === void 0 ? void 0 : exports.gCloudConnections.length) { exports.gCloudConnections.forEach(ctx => ctx.cleanupSockets()); log(`[PROXY] → [CLOUD] Cleaned up all ${exports.gCloudConnections.length} cloud connections.`); exports.gCloudConnections.splice(0, exports.gCloudConnections.length); // clear the array } if (connectionChecker) { clearInterval(connectionChecker); connectionChecker = null; } if (restart) { log(`[PROXY] → [CLOUD] Restarting proxy...`); let connectionsStarted = 0; // Start connection for the master device if uniqueId is configured if (config.uniqueId && config.masterAddress) { const masterId = `${config.uniqueId}:${config.masterPort}`; log(`[PROXY] Starting proxy for master device: ${masterId}`); makeNewCloudConnection(config.masterAddress, config.masterPort, config.cloudServer, config.cloudPort, masterId); connectionsStarted++; } else if (config.uniqueId) { log(`[PROXY] → [CLOUD] Master uniqueId configured but no masterAddress, skipping master connection.`); } // Start TCP proxy for Config API if masterConfigPort is configured // Use uniqueId:8080 pattern (or configUniqueId if explicitly set for backward compatibility) if (config.uniqueId && config.masterAddress && config.masterConfigPort) { const configId = config.configUniqueId || `${config.uniqueId}:${config.masterConfigPort}`; log(`[PROXY] Starting TCP proxy for Config API: ${configId} -> ${config.masterAddress}:${config.masterConfigPort}`); // All connections use the same cloud port for WebSocket multiplexing makeNewCloudConnection(config.masterAddress, config.masterConfigPort, config.cloudServer, config.cloudPort, configId); connectionsStarted++; } if (connectionsStarted > 0) { log(`[PROXY] Started ${connectionsStarted} proxy connection(s) with config: ${JSON.stringify(config, null, 2)}`); // check every 16 second for a free connection connectionChecker = setInterval(() => { const freeConnection = exports.gCloudConnections.find((ctx) => (!ctx.deviceSocket) && ctx.cloudSocket && (ctx.cloudSocket.readyState === 'open')); if (freeConnection) { debug(`[PROXY] → [CLOUD] Found free connection #${freeConnection.uniqueId} to the Cloud -> we're still ok.`); } else { error(`[PROXY] → [CLOUD] No free connections available, restarting proxy. ***************************`); cleanStart(true); } }, 16 * 1000); } else { log(`[PROXY] → [CLOUD] Not restarting, no unique IDs configured, not starting the proxy.`); } } else { log(`[PROXY] → [CLOUD] Not restarting, no new cloud connection, just cleaning up.`); } } exports.cleanStart = cleanStart; class Context { constructor(cloudSocket, master, masterPort, server, serverPort, uniqueId) { this.deviceSocket = null; this.master = master; this.masterPort = masterPort; this.cloudSocket = cloudSocket; this.server = server; this.serverPort = serverPort; this.uniqueId = uniqueId; // Gateway connections have no master address (they handle HTTP, not TCP proxy) this.isGateway = !master || master === ""; this.deviceStatus = { connected: false, lastAttempt: null, lastSuccess: null, lastError: null, retryCount: 0 }; this.setupCloudSocket(); } setupCloudSocket() { // set up handlers for the cloud socket this.cloudSocket.on('data', (data) => { this.handleDataFromCloud(data); }); this.cloudSocket.on('close', () => { warning('[CLOUD] Connection closed'); if (this.deviceSocket) { this.deviceSocket.end(); this.deviceSocket = null; } }); this.cloudSocket.on('error', err => { error(`[CLOUD] Error: ${err.message}`); if (this.deviceSocket) { this.deviceSocket.end(); this.deviceSocket = null; } }); } handleDataFromCloud(data) { debug(`[CLOUD] → [PROXY]: Data from Cloud: ${data.toString()}`); if (!this.deviceSocket) { // we either have a real client that wants to connect to the device, // or we receive a heartbeat from the cloud server // or we receive a connection response [OK], [OK-=2], [ERROR-...] if (this.isHeartbeatRequest(data)) { debug(`[CLOUD] → [PROXY]: Received heartbeat from Cloud, returning response.`); // respond to the heartbeat of the cloud server this.cloudSocket.write("[72,3]"); // command([Command.HeartbeatStatus, CommandMethod.Heartbeat.server]) } else if (this.isConnectionResponse(data)) { debug(`[CLOUD] → [PROXY]: Received connection response from Cloud: ${data.toString()}`); // Handle connection response from the cloud server } else { // There is real incoming data, it's a fresh connection, so: a new client wants to connect if (this.isGateway) { // Gateway connection - forward HTTP request to local HTTP gateway on port 5002 log(`[PROXY] → [CLOUD] New client connection for gateway: ${this.uniqueId}.`); makeNewCloudConnection(this.master, this.masterPort, this.server, this.serverPort, this.uniqueId); this.makeDeviceConnection("localhost", 5002, data); } else { // Device connection - create TCP proxy to master device // 1) create a new (free) connection to the cloud server for the next client // 2) connect to the device and send the initial data log(`[PROXY] → [CLOUD] New client connection, creating new free connection for this proxy/device: ${this.uniqueId}.`); makeNewCloudConnection(this.master, this.masterPort, this.server, this.serverPort, this.uniqueId); this.makeDeviceConnection(this.master, this.masterPort, data); } } } else if (this.deviceSocket.readyState === 'open') { this.deviceSocket.write(data); debug(`[PROXY] → [DEVICE] forwarding data to device: ${data.toString().trim()}`); } else { warning(`[PROXY] → [DEVICE] Device socket is not open yet, waiting for connection... what to do with the data??`); } } makeDeviceConnection(address, port, data) { log(`[PROXY] → [DEVICE] Attempting to connect to local device at ${address}:${port}`); this.deviceStatus.lastAttempt = new Date(); if (!this.deviceSocket) { this.deviceSocket = new net.Socket(); // Add error handler BEFORE connecting to prevent uncaught exceptions this.deviceSocket.on('error', (err) => { error(`[DEVICE] → [PROXY] Connection error: ${err.message}`); // Update device status this.deviceStatus.connected = false; this.deviceStatus.lastError = err.message; this.deviceStatus.retryCount++; log(`[DEVICE] → [PROXY] Connection failed. Will retry on next cloud request.`); // Clean up this socket if (this.deviceSocket && !this.deviceSocket.destroyed) { this.deviceSocket.destroy(); } this.deviceSocket = null; // Don't create a new cloud connection - we already have one! // The device connection will be retried when the next data comes from the cloud }); // Connect to local device and send the data that came in from the cloud this.deviceSocket.connect(port, address, () => { // Check if socket is still valid (not cleaned up by removeConnection) if (!this.deviceSocket) { warning(`[PROXY] → [DEVICE] Device socket was cleaned up before connection completed`); return; } log(`[PROXY] → [DEVICE] Connected to local device at ${address}:${port}`); // Update device status this.deviceStatus.connected = true; this.deviceStatus.lastSuccess = new Date(); this.deviceStatus.lastError = null; this.deviceStatus.retryCount = 0; this.setUpDeviceSocket(); log(`[PROXY] → [DEVICE] Sending initial message: ${data.toString().trim()}`); this.deviceSocket.write(data); }); } else if (this.deviceSocket.readyState === 'open') { warning(`[PROXY] → [DEVICE] Device socket already open, sending data directly -- STRANGE !!`); this.deviceSocket.write(data); } else { error(`[PROXY] → [DEVICE] Device socket exists, but is not open yet !!`); } } setUpDeviceSocket() { if (!this.deviceSocket) { error(`[PROXY] → [DEVICE] Cannot set up device socket - socket is null`); return; } this.deviceSocket.on('data', (data) => { const message = data.toString('utf-8'); debug(`[DEVICE] → [PROXY] forwarding data to Cloud: ${message.trim()}`); this.cloudSocket.write(data); }); this.deviceSocket.on('close', () => { log(`[DEVICE] → [PROXY] Device socket closed`); this.removeConnection(); }); // Note: error handler is already added in makeDeviceConnection() // Don't add duplicate error handler here } cleanupSockets() { // Clean up the device socket if (this.deviceSocket) { if (!this.deviceSocket.destroyed) { this.deviceSocket.removeAllListeners(); this.deviceSocket.end(); this.deviceSocket.destroy(); } this.deviceSocket = null; } // Clean up the cloud socket if (this.cloudSocket) { if (!this.cloudSocket.destroyed) { this.cloudSocket.removeAllListeners(); this.cloudSocket.end(); this.cloudSocket.destroy(); } this.cloudSocket = null; } } removeConnection() { const index = exports.gCloudConnections.indexOf(this); if (index !== -1) exports.gCloudConnections.splice(index, 1); this.cleanupSockets(); log(`[PROXY] → [CLOUD] Closed socket to Cloud and removed context for device ${this.master}`); if (exports.gCloudConnections && exports.gCloudConnections.length === 0) { log(`[PROXY] → [CLOUD] No more cloud connections, restarting proxy.`); // just to be sure: restart... if (config.kind === "pm2") { // PM2 should restart us. log(`[PROXY] → [CLOUD] PM2 should restart us, exiting now.`); setTimeout(() => process.exit(0), 100); } else if (config.kind === "solo") { log(`[PROXY] → [CLOUD] Running solo, restarting proxy.`); restart(); } else { log(`[PROXY] → [CLOUD] Running under gateway/homebridge, not restarting... not sure what to do here.`); } } } /////////////// // Utilities // /////////////// // check if it's a heartbeat request from the server isHeartbeatRequest(data) { const msg = data.toString('utf-8').trim(); return msg === '[215,3]'; // hearbeatKind = (poll = 1, poll_old = 2, serve = 3) // return ((data[0] === 91) && // [ // (data[1] === 50) && // 2 // (data[2] === 49) && // 1 // (data[3] === 53) && // 5 // (data[4] === 44) && // , // (data[5] === 51) && // 3 // (data[6] === 93)); // ] } // check if it's a server response after connecting and sending our uniqueID isConnectionResponse(data) { const message = data.toString(); // Check for various server response patterns return (message.startsWith('[OK')) || (message.startsWith('[ERROR')); } } exports.Context = Context; function restart() { console.log('🔄 Restarting...'); setTimeout(() => { (0, child_process_1.spawn)(process_1.execPath, process_1.argv.slice(1), { cwd: (0, process_1.cwd)(), detached: true, stdio: 'inherit', }); process.exit(0); }, 100); } // Graceful shutdown handler function handleShutdown(signal) { return __awaiter(this, void 0, void 0, function* () { log(`[PROXY] → [SYSTEM] Received ${signal}, shutting down gracefully...`); if (connectionChecker) { clearInterval(connectionChecker); connectionChecker = null; } // Close all cloud connections if (exports.gCloudConnections && exports.gCloudConnections.length > 0) { exports.gCloudConnections.forEach(ctx => ctx.cleanupSockets()); exports.gCloudConnections.splice(0, exports.gCloudConnections.length); } // Close HTTP servers if available if (gWebApp && typeof gWebApp.closeServers === 'function') { log(`[PROXY] → [SYSTEM] Closing HTTP servers...`); try { yield gWebApp.closeServers(); } catch (e) { error(`[PROXY] → [SYSTEM] Error closing servers: ${e.message}`); } } log(`[PROXY] → [SYSTEM] All connections closed.`); if (config.kind === "solo") { log(`[PROXY] → [SYSTEM] Restarting proxy in solo mode...`); restart(); } else { log(`[PROXY] → [SYSTEM] Running under HomeBridge/Gateway - NOT calling process.exit() to avoid restarting entire system.`); log(`[PROXY] → [SYSTEM] Plugin will remain running. Check /settings page for connection status.`); // Don't call process.exit() when running under HomeBridge! // This would restart the entire HomeBridge + UI system } }); } // Listen for termination signals process.on('SIGINT', () => handleShutdown('SIGINT')); process.on('SIGTERM', () => handleShutdown('SIGTERM')); process.on('SIGINT', (err) => { error(`[SYSTEM] received SIGINT`); handleShutdown('SIGINT'); }); process.on('SIGTERM', (err) => { error(`[SYSTEM] received SIGTERM`); handleShutdown('SIGTERM'); }); process.on('uncaughtException', (err) => { error(`[SYSTEM] Uncaught Exception: ${err.message || err}`); handleShutdown('uncaughtException'); }); process.on('unhandledRejection', (reason) => { // Handle different types of rejection reasons try { const message = reason instanceof Error ? reason.message : typeof reason === 'object' ? JSON.stringify(reason) : String(reason); error(`[SYSTEM] Unhandled Rejection: ${message}`); // Also log the stack trace if available if (reason instanceof Error && reason.stack) { console.error('Stack trace:', reason.stack); } } catch (e) { error(`[SYSTEM] Unhandled Rejection: [Error converting reason to string]`); } handleShutdown('unhandledRejection'); }); //# sourceMappingURL=proxy.js.map