UNPKG

homebridge-sony-adcp-projector

Version:

Homebridge accessory to control Sony projectors over ADCP (IP). Exposes a HomeKit Television tile with input sources.

359 lines (316 loc) 10.3 kB
const net = require("net"); const crypto = require("crypto"); class ADCP { /** * @param {string} ip * @param {number} port * @param {string|undefined} username // not used by ADCP, kept for backwards compat * @param {string|undefined} password // ADCP password (if auth enabled) * @param {object} log // homebridge logger * @param {boolean} useAuth // projector ADCP "Requires Authentication" * @param {number} timeout // base timeout in ms */ constructor( ip, port = 53595, username, password, log, useAuth = false, timeout = 5000, ) { this.ip = ip; this.port = port; this.username = username; this.password = password || ""; this.log = log; this.useAuth = useAuth; this.client = null; // TCP client socket this.isAuthenticated = false; // Authentication state this.commandQueue = []; // Queue for pending commands this.responseBuffer = ""; // Buffer for incoming data this.isConnecting = false; // Connection state this.connectionTimeout = timeout; // Connection timeout this.commandTimeout = timeout; // Per-command timeout // Debug helper (off by default; can be enabled via setDebug(true)) this.debugEnabled = false; this.debug = () => {}; } // Enable or disable verbose debug logs from this ADCP client setDebug(enabled) { this.debugEnabled = !!enabled; this.debug = this.debugEnabled && this.log && this.log.debug ? this.log.debug.bind(this.log) : () => {}; } // Establish connection and authenticate if necessary async connect() { if (this.client && !this.client.destroyed) { return; // already connected } if (this.isConnecting) { await new Promise((resolve) => { const check = setInterval(() => { if (!this.isConnecting) { clearInterval(check); resolve(); } }, 50); }); return; } this.isConnecting = true; this.client = new net.Socket(); return new Promise((resolve, reject) => { const connectionTimer = setTimeout(() => { this.log.error(`⚠️ Connection timeout: ${this.ip}:${this.port}`); this.disconnect(); this.isConnecting = false; reject(new Error("Connection timeout")); }, this.connectionTimeout); // Common handlers this.client.on("error", (error) => { clearTimeout(connectionTimer); this.log.error(`❌ Socket error: ${error.message}`); this.disconnect(); reject(error); }); this.client.on("close", () => { this.log.warn("⚠️ Connection closed by the projector."); this.isAuthenticated = false; this.client = null; }); this.client.connect(this.port, this.ip, async () => { clearTimeout(connectionTimer); try { // Attach data handler now; it won't resolve anything until a command is queued this.client.on("data", (data) => this.handleData(data)); // Improve stability on idling projectors try { this.client.setKeepAlive(true, 15000); } catch (e) { /* noop */ } if (this.useAuth) { await this.authenticate(); this.debug("✅ ADCP authentication successful."); } else { // Drain an initial NOKEY if projector sends it (no-auth mode) this.isAuthenticated = true; } this.debug("ADCP connected"); this.isConnecting = false; resolve(); } catch (error) { this.log.error(`❌ Authentication failed: ${error.message}`); this.disconnect(); this.isConnecting = false; reject(error); } }); }); } // Disconnect the socket disconnect() { if (this.client && !this.client.destroyed) { try { this.client.destroy(); } catch (e) { /* noop */ } } this.client = null; this.isAuthenticated = false; this.commandQueue = []; this.responseBuffer = ""; this.isConnecting = false; } // Handle incoming data (CRLF-delimited lines) handleData(data) { this.responseBuffer += data.toString("utf8"); let idx; while ((idx = this.responseBuffer.indexOf("\r\n")) !== -1) { const line = this.responseBuffer.slice(0, idx).trim(); this.responseBuffer = this.responseBuffer.slice(idx + 2); // Ignore ADCP banner lines that can arrive in the same frame as command responses if (line === "NOKEY" || line.length === 0) { this.debug("[ADCP] banner ignored:", JSON.stringify(line)); continue; } if (this.commandQueue.length > 0) { const { resolve } = this.commandQueue.shift(); resolve(line); } else { // No pending commands; treat as informational only this.debug("[ADCP] unsolicited:", JSON.stringify(line)); } } } // ADCP authenticate: either NOKEY (no auth) or nonce -> sha256(nonce+password) authenticate() { return new Promise((resolve, reject) => { let nonce = ""; const timer = setTimeout(() => { cleanup(); reject(new Error("Authentication timeout")); }, this.commandTimeout); const onData = (buf) => { const chunk = buf.toString("utf8"); const lines = chunk .split(/\r\n/) .map((l) => l.trim()) .filter(Boolean); for (const lineRaw of lines) { const line = lineRaw.trim(); if (!line) continue; // No-auth mode explicitly tells us NOKEY if (line === "NOKEY") { cleanup(); this.isAuthenticated = true; return resolve(); } if (!nonce) { // First non-empty line is the nonce nonce = line; const hash = crypto .createHash("sha256") .update(nonce + this.password, "utf8") .digest("hex"); this.client.write(hash + "\r\n"); this.debug("ADCP >> [auth hash]"); continue; } // After sending hash, expect 'ok' on success const l = line.toLowerCase(); if (l.startsWith("ok")) { cleanup(); this.isAuthenticated = true; return resolve(); } if (l.startsWith("err")) { cleanup(); return reject(new Error(`ADCP auth error: ${line}`)); } } }; const cleanup = () => { clearTimeout(timer); this.client?.removeListener("data", onData); }; // Temporary listener just for auth handshake; handleData will ignore until commands are queued this.client.on("data", onData); }); } // Send a command and receive a single-line response sendCommand(command) { return new Promise((resolve, reject) => { if (!this.client || this.client.destroyed) { return reject(new Error("Socket is not connected")); } const commandTimer = setTimeout(() => { this.log.error(`⚠️ Command timeout: '${command}'`); this.disconnect(); reject(new Error("Command timeout")); }, this.commandTimeout); this.commandQueue.push({ resolve: (response) => { clearTimeout(commandTimer); this.debug(`ADCP << ${response}`); resolve(response); }, reject: (error) => { clearTimeout(commandTimer); reject(error); }, }); this.debug(`ADCP >> ${command}`); this.client.write(`${command}\r\n`); }); } // Execute a command, ensuring connection and authentication async executeCommand(command) { await this.connect(); const response = await this.sendCommand(command); return response; } // Power Commands async getPowerState() { try { const resp = (await this.executeCommand("power_status ?")).toLowerCase(); // Typical returns: 'on', 'standby', 'cooling1', 'cooling2' return resp === "on"; } catch (error) { this.log.error("Error getting power state:", error.message); throw error; } } async setPowerState(state) { try { const resp = ( await this.executeCommand(`power "${state ? "on" : "off"}"`) ).toLowerCase(); if (resp !== "ok") throw new Error(`Power set failed: ${resp}`); return true; } catch (error) { this.log.error("Error setting power state:", error.message); throw error; } } // Network Commands async getIpAddress() { const resp = await this.executeCommand("ipv4_ip_address ?"); return resp; } async getNetworkStatus() { const resp = await this.executeCommand("ipv4_network_setting ?"); return resp; } // Error and Warning Status async getErrorStatus() { const resp = await this.executeCommand("error ?"); if (resp && resp.startsWith("{")) { try { return JSON.parse(resp); } catch { return { raw: resp }; } } return { raw: resp }; } async getWarningStatus() { const resp = await this.executeCommand("warning ?"); return resp; } // Input Selection async setInputSource(source) { const resp = (await this.executeCommand(`input "${source}"`)).toLowerCase(); if (resp !== "ok") throw new Error(`Failed to set input source: ${resp}`); return true; } // Image Adjustment async setBrightness(value) { const resp = ( await this.executeCommand(`brightness ${value}`) ).toLowerCase(); if (resp !== "ok") throw new Error(`Failed to set brightness: ${resp}`); return true; } async setContrast(value) { const resp = (await this.executeCommand(`contrast ${value}`)).toLowerCase(); if (resp !== "ok") throw new Error(`Failed to set contrast: ${resp}`); return true; } async setGamma(mode) { const resp = ( await this.executeCommand(`gamma_correction "${mode}"`) ).toLowerCase(); if (resp !== "ok") throw new Error(`Failed to set gamma mode: ${resp}`); return true; } // Shutdown method to clean up resources shutdown() { this.disconnect(); } } module.exports = ADCP;