UNPKG

homebridge-sony-adcp-projector

Version:

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

694 lines (625 loc) 21.2 kB
const net = require("net"); const crypto = require("crypto"); const EventEmitter = require("events"); const { ADCP_COMMANDS } = require("./adcp-commands"); class ADCP extends EventEmitter { /** * @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, ) { super(); 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 // Retry controls this.maxRetries = 1; // number of automatic retries per command on transient errors this.retryDelayMs = 150; // delay between retries // Command pacing this.debounceMs = 200; this.lastCommandTime = 0; this._pendingCommand = Promise.resolve(); // Offline cooldown - prevent hammering a dead projector this._lastConnectionFailure = 0; this._connectionCooldownMs = 10000; // 10 seconds between retry attempts when offline // 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 } // Check if we're in cooldown from a recent connection failure const now = Date.now(); if ( this._lastConnectionFailure && now - this._lastConnectionFailure < this._connectionCooldownMs ) { const remaining = Math.ceil( (this._connectionCooldownMs - (now - this._lastConnectionFailure)) / 1000, ); this.debug( `Connection in cooldown (${remaining}s remaining), skipping attempt`, ); throw new Error("Connection cooldown active"); } 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(() => { const err = new Error("Connection timeout"); this.log.warn(`⚠️ Connection timeout: ${this.ip}:${this.port}`); this._lastConnectionFailure = Date.now(); // Start cooldown this.disconnect(); this.isConnecting = false; this.emit("error", err); reject(err); }, this.connectionTimeout); // Common handlers this.client.on("error", (error) => { clearTimeout(connectionTimer); this.log.warn(`❌ Socket error: ${error.message}`); this._lastConnectionFailure = Date.now(); // Start cooldown this.disconnect(); this.emit("error", error); reject(error); }); this.client.on("close", () => { this.log.warn("⚠️ Connection closed by the projector."); this.disconnect(); this.emit("disconnected", { ip: this.ip, port: this.port }); }); 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.removeAllListeners("data"); 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._lastConnectionFailure = 0; // Clear cooldown on success this.isConnecting = false; this.emit("connected", { ip: this.ip, port: this.port, authenticated: this.isAuthenticated, }); resolve(); } catch (error) { this.log.error(`❌ Authentication failed: ${error.message}`); this.disconnect(); this.isConnecting = false; this.emit("error", error); 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; // Reject any in-flight commands const pending = this.commandQueue.splice(0); for (const item of pending) { try { item.reject?.(new Error("Disconnected")); } catch (_) { /* noop */ } } 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; ignore common keepalive/ok noise, log others if (line.toLowerCase() !== "ok") { 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; this.emit("authenticated", { method: "none" }); 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(); const err = new Error(`ADCP auth error: ${line}`); this.emit("error", err); return reject(err); } } }; 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) { const attemptSend = (attempt = 0) => { 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}'`); // Kill the socket so a reconnect happens on next call this.disconnect(); if (attempt < this.maxRetries) { return setTimeout(() => { this.connect() .then(() => attemptSend(attempt + 1) .then(resolve) .catch(reject), ) .catch(reject); }, this.retryDelayMs); } return reject(new Error("Command timeout")); }, this.commandTimeout); const done = (fn) => (value) => { clearTimeout(commandTimer); fn(value); }; this.commandQueue.push({ resolve: done(resolve), reject: done(reject), }); try { this.debug(`ADCP >> ${command}`); this.client.write(`${command}\r\n`); } catch (err) { clearTimeout(commandTimer); this.log.error(`❌ Write failed: ${err.message}`); if (attempt < this.maxRetries) { return setTimeout(() => { this.connect() .then(() => attemptSend(attempt + 1) .then(resolve) .catch(reject), ) .catch(reject); }, this.retryDelayMs); } return reject(err); } }); }; return attemptSend(0); } // Execute a command, ensuring connection and authentication async executeCommand(command) { return this._enqueueCommand(async () => { try { await this.connect(); return await this.sendCommand(command); } catch (err) { this.emit("error", err); // One-shot reconnect in case the socket dropped between calls this.disconnect(); await this.connect(); return await this.sendCommand(command); } }); } _enqueueCommand(fn) { const run = async () => { const now = Date.now(); const elapsed = now - this.lastCommandTime; if (elapsed < this.debounceMs) { await delay(this.debounceMs - elapsed); } try { return await fn(); } finally { this.lastCommandTime = Date.now(); } }; const next = this._pendingCommand.then(run, run); this._pendingCommand = next.catch(() => {}); return next; } // ---- Internal parsers ------------------------------------------------- _isOk(resp) { if (!resp) return false; const r = String(resp).trim().toLowerCase(); return r === "ok" || r.startsWith("ok"); } _toBool01(resp) { if (resp === undefined || resp === null) return 0; const r = String(resp).trim().toLowerCase(); if (r === "on" || r === "true") return 1; const n = Number(r); if (!Number.isNaN(n)) return n ? 1 : 0; return r === "ok" ? 1 : 0; } _toNumber(resp, def = 0) { const n = Number(String(resp).trim()); return Number.isFinite(n) ? n : def; } // Power Commands async getPowerState() { try { const raw = await this.executeCommand("power_status ?"); // Response may include quotes, e.g. '"on"' - strip them const resp = raw.toLowerCase().replace(/"/g, "").trim(); this.debug(`getPowerState raw="${raw}" parsed="${resp}"`); // 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(ADCP_COMMANDS.IPV4_ADDRESS); return resp; } async getNetworkStatus() { const resp = await this.executeCommand(ADCP_COMMANDS.IPV4_NETWORK_SETTING); return resp; } // Ping-ish command: perform a no-op query that every unit should accept async ping() { try { await this.executeCommand(ADCP_COMMANDS.POWER_STATUS); return true; } catch { return false; } } // Model name (if supported by firmware) async getModelName() { try { const resp = await this.executeCommand(ADCP_COMMANDS.MODEL_QUERY); return String(resp || "") .replace(/"/g, "") .trim(); } catch { return ""; } } // Serial number async getSerialNumber() { try { const resp = await this.executeCommand(ADCP_COMMANDS.SERIAL_QUERY); return String(resp || "") .replace(/"/g, "") .trim(); } catch { return ""; } } // Firmware version - returns JSON like [{"main":"1.104"},{"laser":"21/00/00/00/00"}] async getFirmwareVersion() { try { const resp = await this.executeCommand(ADCP_COMMANDS.FIRMWARE_QUERY); const str = String(resp || "").trim(); // Try to parse JSON response if (str.startsWith("[")) { const arr = JSON.parse(str); const main = arr.find((item) => item.main); if (main?.main) return main.main; } // Fallback: return raw response without quotes return str.replace(/"/g, "").trim(); } catch { return ""; } } // Error and Warning Status async getErrorStatus() { const resp = await this.executeCommand(ADCP_COMMANDS.ERROR_STATUS); if (resp && resp.startsWith("{")) { try { return JSON.parse(resp); } catch { return { raw: resp }; } } return { raw: resp }; } async getWarningStatus() { const resp = await this.executeCommand(ADCP_COMMANDS.WARNING_STATUS); return resp; } // Input Selection async setInputSource(source) { const resp = ( await this.executeCommand(ADCP_COMMANDS.INPUT_SET(source)) ).toLowerCase(); if (resp !== "ok") throw new Error(`Failed to set input source: ${resp}`); return true; } // Query current input identifier (ADCP input code) // VPL-XW5000 returns string like "hdmi1", "hdmi2" async getInputIdentifier() { try { const resp = await this.executeCommand(ADCP_COMMANDS.INPUT_QUERY); // Strip quotes if present const input = String(resp || "") .replace(/"/g, "") .trim() .toLowerCase(); return input || "unknown"; } catch { return "unknown"; } } // Image Adjustment async setBrightness(value) { const val = Math.max(0, Math.min(100, Number(value))); const resp = ( await this.executeCommand(ADCP_COMMANDS.BRIGHTNESS_SET(val)) ).toLowerCase(); if (resp !== "ok") throw new Error(`Failed to set brightness: ${resp}`); return true; } async setContrast(value) { const val = Math.max(0, Math.min(100, Number(value))); const resp = ( await this.executeCommand(ADCP_COMMANDS.CONTRAST_SET(val)) ).toLowerCase(); if (resp !== "ok") throw new Error(`Failed to set contrast: ${resp}`); return true; } // ---- Contrast (getter) ----------------------------------------------- async getContrast() { const resp = await this.executeCommand(ADCP_COMMANDS.CONTRAST_QUERY); return this._toNumber(resp, 50); } async setGamma(mode) { const resp = ( await this.executeCommand(ADCP_COMMANDS.GAMMA_SET(mode)) ).toLowerCase(); if (resp !== "ok") throw new Error(`Failed to set gamma mode: ${resp}`); return true; } // ---- Video Mute (Blank Screen) --------------------------------------- // VPL-XW5000 uses 'blank ?' and returns "on" or "off" async getVideoMute() { const resp = await this.executeCommand(ADCP_COMMANDS.VIDEO_MUTE_QUERY); const val = String(resp || "") .replace(/"/g, "") .trim() .toLowerCase(); return val === "on" || val === "1"; } async setVideoMute(on) { const resp = await this.executeCommand(ADCP_COMMANDS.VIDEO_MUTE_SET(on)); if (!this._isOk(resp)) throw new Error(`Failed to set video mute: ${resp}`); return true; } // ---- Picture Mode ----------------------------------------------------- // VPL-XW5000 returns string like "reference", "cinema_film_1", etc. async getPictureMode() { const resp = await this.executeCommand(ADCP_COMMANDS.PICTURE_MODE_QUERY); // Strip quotes and return string return String(resp || "") .replace(/"/g, "") .trim(); } async setPictureMode(mode) { // Accept string mode name (e.g., "reference", "cinema_film_1") const resp = await this.executeCommand( ADCP_COMMANDS.PICTURE_MODE_SET(mode), ); if (!this._isOk(resp)) throw new Error(`Failed to set picture mode: ${resp}`); return true; } // ---- HDR Mode --------------------------------------------------------- // VPL-XW5000 returns string like "auto", "off", "hdr10", "hlg" async getHdrMode() { const resp = await this.executeCommand(ADCP_COMMANDS.HDR_MODE_QUERY); // Strip quotes and return string return String(resp || "") .replace(/"/g, "") .trim(); } async setHdrMode(mode) { // Accept string mode name (e.g., "auto", "off", "hdr10", "hlg") const resp = await this.executeCommand(ADCP_COMMANDS.HDR_MODE_SET(mode)); if (!this._isOk(resp)) throw new Error(`Failed to set HDR mode: ${resp}`); return true; } // ---- Brightness (getter) --------------------------------------------- async getBrightness() { const resp = await this.executeCommand(ADCP_COMMANDS.BRIGHTNESS_QUERY); return this._toNumber(resp, 50); } // ---- Error / Warning (compact) --------------------------------------- // VPL-XW5000 returns ["no_err"] or error array async getErrorCode() { const resp = await this.executeCommand(ADCP_COMMANDS.ERROR_STATUS); const str = String(resp || "").trim(); // Parse JSON array response if (str.startsWith("[")) { try { const arr = JSON.parse(str); // Return 0 if no_err, otherwise 1 (has error) if (arr.includes("no_err")) return 0; return arr.length > 0 ? 1 : 0; } catch {} } // Fallback to numeric parsing const n = this._toNumber(resp, 0); return Number.isFinite(n) ? n : 0; } // ---- Lamp / Laser Hours ---------------------------------------------- // VPL-XW5000 laser info is in version response: [{"main":"1.104"},{"laser":"21/00/00/00/00"}] // The first number in laser string appears to be hours async getLampHours() { try { const resp = await this.executeCommand(ADCP_COMMANDS.LAMP_HOURS); const str = String(resp || "").trim(); if (str.startsWith("[")) { const arr = JSON.parse(str); const laserObj = arr.find((item) => item.laser); if (laserObj?.laser) { // Parse "21/00/00/00/00" - first number is hours const parts = laserObj.laser.split("/"); return parseInt(parts[0], 10) || 0; } } return this._toNumber(resp, 0); } catch { return 0; } } // ---- Test Patterns (advanced) ---------------------------------------- async setTestPattern(pattern) { // Accept string pattern name const resp = await this.executeCommand( ADCP_COMMANDS.TEST_PATTERN_SET(pattern), ); if (!this._isOk(resp)) throw new Error(`Failed to set test pattern: ${resp}`); return true; } // Shutdown method to clean up resources shutdown() { this.disconnect(); } } module.exports = ADCP; function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }