homebridge-smartsystem
Version:
SmartServer (Proxy TCP sockets to the cloud, Smappee MQTT, Duotecno IP Nodes, Homekit interface)
463 lines • 21.6 kB
JavaScript
;
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