homebridge-smartsystem
Version:
SmartServer (Proxy Websockets to TCP sockets, Smappee MQTT, Duotecno IP Nodes, Homekit interface)
316 lines (257 loc) • 10.3 kB
text/typescript
import * as net from 'net';
import * as WebSocket from 'ws';
import { spawn } from 'child_process';
import { argv, cwd, execPath } from 'process';
//
// [ device (TCP) ] ←→ [ Proxy = WebSocket client (ws) ] ←→ [ Cloud = 2 x WebSocket server (ws) ] ←→ [ browser client ]
//
export interface ProxyConfig {
cloudServer: string; // cloud server address
cloudPort: number; // cloud server port
masterAddress: string; // local master address
masterPort: number; // local master port
uniqueId: string; // unique ID for the device (for now the no-ip address + port)
debug?: boolean; // debug mode
kind?: "pm2" | "gw" | "solo";
}
export const kEmptyProxy: ProxyConfig = {
cloudServer: "ws.duotecno.eu",
cloudPort: 5098,
masterAddress: "",
masterPort: 5001,
uniqueId: "", // no unique ID -> don't start the proxy
debug: false, // default nodebug mode
kind: "solo" // default running under PM2 or other process manager -> don't restart yourself
}
export function setProxyConfig(c?: ProxyConfig): ProxyConfig {
// set the proxy config
if (c) {
config.cloudServer = c.cloudServer || kEmptyProxy.cloudServer;
config.cloudPort = c.cloudPort || kEmptyProxy.cloudPort;
config.masterAddress = c.masterAddress || kEmptyProxy.masterAddress;
config.masterPort = c.masterPort || kEmptyProxy.masterPort;
config.uniqueId = c.uniqueId || kEmptyProxy.uniqueId;
config.debug = !!c.debug;
config.kind = c.kind || kEmptyProxy.kind;
} else {
config = {...kEmptyProxy};
}
return config;
}
let config: ProxyConfig;
try {
setProxyConfig(require('../config-proxy.json'))
} catch (e) {
setProxyConfig();
console.log("No config-proxy.json found, using default values from homebridge or other.");
}
const gCloudConnections: Array<Context> = [];
const kDebug = config.debug || false; // enable debug mode from config
/////////////
// Logging //
/////////////
function debug(str: string) {
if (kDebug) {
console.log(`DEBUG: ${str}`);
}
}
function warning(str: string) {
console.warn(`WARNING: ${str}`);
}
function log(str: string) {
console.log(`LOG: ${str}`);
}
function error(str: string) {
console.error(`ERROR: **** ${str} ****`);
}
///////////////////////////////
// Start up the proxy server //
///////////////////////////////
// if (config.kind != "gw") {
// makeNewCloudConnection(config.masterAddress, config.masterPort, config.cloudServer, config.cloudPort, config.uniqueId);
// }
export function makeNewCloudConnection(master: string, masterPort: number, server: string, serverPort: number, uniqueId: string) {
const cloudSocket = new WebSocket('ws://'+server+':'+serverPort+"/"+uniqueId);
cloudSocket.on('open', () => {
log(`[PROXY] → [CLOUD] Connected to Cloud at ${server}:${serverPort}`);
const context = new Context(cloudSocket, master, masterPort, server, serverPort, uniqueId);
gCloudConnections.push(context);
log(`[PROXY] → [CLOUD] New free connection #${gCloudConnections.length} to the Cloud at ${server}:${serverPort}`);
});
log(`[PROXY] → [CLOUD] Attempting to start a new free connection for ${config.uniqueId} to the Cloud at ${server}:${serverPort}`);
}
////////////////
// Restarting //
////////////////
export function cleanStart(restart: boolean = false) {
if (gCloudConnections.length) {
gCloudConnections.forEach(context => {
if (context.deviceSocket) {
context.deviceSocket.end();
context.deviceSocket = null;
}
context.cloudSocket.close();
context.cloudSocket = null;
});
gCloudConnections.splice(0, gCloudConnections.length); // clear the array
log(`[PROXY] → [CLOUD] Cleaned up all cloud connections.`);
}
if (restart) {
if (config.uniqueId) {
makeNewCloudConnection(config.masterAddress, config.masterPort, config.cloudServer, config.cloudPort, config.uniqueId);
} else {
log(`[PROXY] → [CLOUD] Not restarting, no unique ID configured, not starting the proxy.`);
}
} else {
log(`[PROXY] → [CLOUD] Not restarting, no new cloud connection, just cleaning up.`);
}
}
export class Context {
deviceSocket: net.Socket;
master: string;
masterPort: number;
cloudSocket: WebSocket;
server: string;
serverPort: number;
// unique identifier for this device-gateway combination
uniqueId: string;
constructor(cloudSocket: WebSocket, master: string, masterPort: number, server: string, serverPort: number, uniqueId: string) {
this.deviceSocket = null;
this.master = master;
this.masterPort = masterPort;
this.cloudSocket = cloudSocket;
this.server = server;
this.serverPort = serverPort;
this.uniqueId = uniqueId;
this.setupCloudSocket();
}
setupCloudSocket() {
// set up handlers for the cloud socket
this.cloudSocket.on('message', message => {
this.handleDataFromCloud(message as Buffer);
});
this.cloudSocket.on('close', (code, reason) => {
warning('[CLOUD] Connection closed: ' + code + ' ' + reason);
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: Buffer) {
debug(`[CLOUD] → [PROXY]: Data from Cloud: ${data.toString().slice(0, -1)}`);
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
if (this.isHeartbeatRequest(data)) {
debug(`[CLOUD] → [PROXY]: Received heartbeat from Cloud, returning response.`);
// respond to the heartbeat of the cloud server
this.cloudSocket.send("[72,3]"); // command([Command.HeartbeatStatus, CommandMethod.Heartbeat.server])
} else {
// There is incomming data, it's a fresh connection, so: a new client wants to connect to the 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().slice(0, -1)}`);
} else {
warning(`[PROXY] → [DEVICE] Device socket is not open yet, waiting for connection... what to do with the data??`);
}
}
makeDeviceConnection(address: string, port: number, data: Buffer) {
log(`[PROXY] → [DEVICE] Attempting to connect to local device at ${address}:${port}`);
if (!this.deviceSocket) {
this.deviceSocket = new net.Socket();
// Connect to local device and send the data that came in from the cloud
this.deviceSocket.connect(port, address, () => {
log(`[PROXY] → [DEVICE] Connected to local device at ${address}:${port}`);
this.setUpDeviceSocket();
log(`[PROXY] → [DEVICE] Sending initial message: ${data.toString().slice(0, -1)}`);
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() {
this.deviceSocket.on('data', (data: Buffer) => {
const message = data.toString('utf-8');
debug(`[DEVICE] → [PROXY] forwarding data to Cloud: ${message.slice(0, -1)}`);
this.cloudSocket.send(message);
});
this.deviceSocket.on('close', () => {
log(`[DEVICE] → [PROXY] Device socket closed`);
this.removeConnection()
});
this.deviceSocket.on('error', (err) => {
error(`[DEVICE] → [PROXY] Device socket error: ${err.message}`);
this.removeConnection()
});
}
removeConnection() {
this.deviceSocket = null;
// remove this context and close the cloud socket
const index = gCloudConnections.indexOf(this);
if (index !== -1) {
this.cloudSocket.close();
this.cloudSocket = null;
gCloudConnections.splice(index, 1);
log(`[PROXY] → [CLOUD] Closed socket to Cloud and removed context for device ${this.master}`);
}
if (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.`);
process.exit();
} else if (config.kind === "solo"){
log(`[PROXY] → [CLOUD] Running solo, restarting proxy.`);
this.restart();
} else {
log(`[PROXY] → [CLOUD] Running under gateway/homebridge, not restarting... not sure what to do here.`);
}
}
}
///////////////
// Utilities //
///////////////
restart() {
console.log('🔄 Restarting...');
spawn(execPath, argv.slice(1), {
cwd: cwd(),
detached: true,
stdio: 'inherit',
});
process.exit();
}
// check if it's a heartbeat request from the server
isHeartbeatRequest(data: Buffer): boolean {
// heartbeatKind = (poll = 1, poll_old = 2, serve = 3)
if (typeof data === 'string') {
return data === "[215,3]";
} else {
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)); // ]
}
}
}