UNPKG

homebridge-daikin-local

Version:

daikin plugin for homebridge: https://github.com/homebridge/homebridge

1,209 lines (1,031 loc) 97.9 kB
/* eslint no-unused-vars: ["warn", {"args": "none"} ] */ /* eslint curly: "off" */ /* eslint logical-assignment-operators: ["error", "always", { enforceForIfStatements: false }] */ /* eslint quotes: ["error", "single", { "avoidEscape": true }] */ /* eslint quote-props: ["error", "consistent-as-needed"] */ /* eslint no-unused-expressions: "warn" */ /* eslint complexity: ["error", 40] */ /* eslint no-negated-condition: "warn" */ let Service; let Characteristic; // Use node: protocol for core modules const https = require('node:https'); const http = require('node:http'); const crypto = require('node:crypto'); const process = require('node:process'); const superagent = require('superagent'); const Throttle = require('superagent-throttle'); const WebSocket = require('ws'); const packageFile = require('../package.json'); const Cache = require('./cache.js'); const Queue = require('./queue.js'); const {parseResponse, parseTemperatureDisplayUnits, daikinSpeedToRaw, rawToDaikinSpeed} = require('./utils.js'); function Daikin(log, config) { this.log = log; const NODE_MAJOR_VERSION = process.versions.node.split('.')[0]; if (NODE_MAJOR_VERSION <= 16) { this.log.warn('WARNING: NodeJS version 16 and older versions are end of life as of 2023-09-11.'); this.log.warn('Visit nodejs.org for more details.'); } this.cache = new Cache(); this.queue = new Queue(); this.displayUnitsDescription = ['Celsius', 'Fahrenheit']; this.throttle = new Throttle({ active: true, // set false to pause queue rate: 1, // how many requests can be sent every `ratePer` ratePer: 500, // number of ms in which `rate` requests may be sent concurrent: 1, // how many requests can be sent concurrently }); if (config.name === undefined) { this.log.error('ERROR: your configuration is missing the parameter "name"'); this.name = 'Unnamed Daikin'; } else { this.name = config.name; this.log.debug('Config: AC name is %s', config.name); } if (config.temperature_unit === undefined) { this.log.error('ERROR: your configuration is missing the parameter "temperature_unit"'); this.temperatureDisplayUnits = Characteristic.TemperatureDisplayUnits.CELSIUS; } else { this.temperatureDisplayUnits = parseTemperatureDisplayUnits( config.temperature_unit, Characteristic.TemperatureDisplayUnits, ); this.log.debug( 'Config: temperature_unit is %s (HomeKit value: %s)', config.temperature_unit, this.temperatureDisplayUnits, ); } if (config.temperatureOffsetOutside === undefined) { this.log.warn('WARNING: your configuration is missing the parameter "temperatureOffsetOutside", using default zero'); this.temperatureOffsetOutside = 0; this.log.debug('Config: temperatureOffsetOutside is %s', this.temperatureOffsetOutside); } else { this.log.debug('Config: temperatureOffsetOutside is %s', config.temperatureOffsetOutside); this.temperatureOffsetOutside = config.temperatureOffsetOutside; } if (config.temperatureOffsetInside === undefined) { this.log.warn('WARNING: your configuration is missing the parameter "temperatureOffsetInside", using default zero'); this.temperatureOffsetInside = 0; this.log.debug('Config: temperatureOffsetInside is %s', this.temperatureOffsetInside); } else { this.log.debug('Config: temperatureOffsetInside is %s', config.temperatureOffsetInside); this.temperatureOffsetInside = config.temperatureOffsetInside; } if (config.apiroute === undefined) { this.log.error('ERROR: your configuration is missing the parameter "apiroute"'); this.apiroute = 'http://127.0.0.1'; this.apiIP = '127.0.0.1'; } else { const myURL = new URL(config.apiroute); this.apiroute = myURL.origin; this.apiIP = myURL.hostname; this.log.debug('Config: apiroute is %s', config.apiroute); } if (config.swingMode === undefined) { this.log.warn('WARNING: your configuration is missing the parameter "swingMode", using default'); this.swingMode = '1'; this.log.debug('Config: swingMode is %s', this.swingMode); } else { this.log.debug('Config: swingMode is %s', config.swingMode); this.swingMode = config.swingMode; } if (config.response === undefined) { this.log.warn('WARNING: your configuration is missing the parameter "response", using default'); this.response = 5000; this.log.debug('Config: response is %s', this.response); } else { this.log.debug('Config: response is %s', config.response); this.response = config.response; } if (config.deadline === undefined) { this.log.warn('WARNING: your configuration is missing the parameter "deadline", using default'); this.deadline = 10_000; this.log.debug('Config: deadline is %s', this.deadline); } else { this.log.debug('Config: deadline is %s', config.deadline); this.deadline = config.deadline; } if (config.retries === undefined) { this.log.warn('WARNING: your configuration is missing the parameter "retries", using default of 5 retries'); this.retries = 5; this.log.debug('Config: retries is %s', this.retries); } else { this.log.debug('Config: retries is %s', config.retries); this.retries = config.retries; } if (config.defaultMode === undefined) { this.log.warn('ERROR: your configuration is missing the parameter "defaultMode", using default'); this.defaultMode = '1'; this.log.debug('Config: defaultMode is %s', this.defaultMode); } else { this.log.debug('Config: defaultMode is %s', config.defaultMode); this.defaultMode = config.defaultMode; } if (config.defaultMode === 0) { this.log.error('ERROR: the parameter "defaultMode" is set to an illegal value of "0". Going to use a value of "1" (Auto) instead.'); this.defaultMode = '1'; } switch (config.fanMode) { case 'FAN': { this.fanMode = '6'; this.log.debug('Config: fanMode is %s', this.fanMode); break;} case 'DRY': { this.fanMode = '2'; this.log.debug('Config: fanMode is %s', this.fanMode); break;} case undefined: { this.log.warn('ERROR: your configuration is missing the parameter "fanMode", using default: FAN'); this.fanMode = '6'; this.log.debug('Config: fanMode is %s', this.fanMode); break;} default: { this.log.error('ERROR: your configuration has an invalid value for parameter "fanMode", using default'); this.fanMode = '6'; this.log.debug('Config: fanMode is %s', this.fanMode); break;} } switch (config.fanPowerMode) { case undefined: { this.log.warn('ERROR: your configuration is missing the parameter "fanPowerMode", using default'); this.fanPowerMode = false; break;} case 'FAN only': { this.fanPowerMode = false; break;} default: { this.fanPowerMode = true; break;} } if (config.fanName === undefined && config.fanMode === undefined) { this.log.warn('ERROR: your configuration is missing the parameter "fanName", using default'); this.fanName = this.name + ' FAN'; this.log.warn('Config: Fan name is %s', this.fanName); } else if (config.fanName === undefined) { this.log.warn('ERROR: your configuration is missing the parameter "fanName", using default'); this.fanName = this.name + ' ' + config.fanMode; this.log.warn('Config: Fan name is %s', this.fanName); } else { this.fanName = config.fanName; this.log.debug('Config: Fan name is %s', this.fanName); } if (config.system === undefined) { this.log.warn('ERROR: your configuration is missing the parameter "system", using default: Default'); this.system = 'Default'; this.log.debug('Config: system is %s', this.system); } else { this.log.debug('Config: system is %s', config.system); this.system = config.system; } /* eslint no-implicit-coercion: "warn" */ this.OpenSSL3 = !!config.OpenSSL3; this.disableFan = !!config.disableFan; this.enableHumiditySensor = !!config.enableHumiditySensor; this.enableTemperatureSensor = !!config.enableTemperatureSensor; this.uuid = config.uuid || ''; // Determine if using Faikout (ESP32-based) or traditional Daikin API this.isFaikin = (this.system === 'Faikin' || this.system === 'Faikout'); switch (this.system) { case 'Default': { this.get_sensor_info = this.apiroute + '/aircon/get_sensor_info'; this.get_control_info = this.apiroute + '/aircon/get_control_info'; this.get_model_info = this.apiroute + '/aircon/get_model_info'; this.set_control_info = this.apiroute + '/aircon/set_control_info'; this.basic_info = this.apiroute + '/common/basic_info'; break;} case 'Skyfi': { this.get_sensor_info = this.apiroute + '/skyfi/aircon/get_sensor_info'; this.get_control_info = this.apiroute + '/skyfi/aircon/get_control_info'; this.get_model_info = this.apiroute + '/skyfi/aircon/get_model_info'; this.set_control_info = this.apiroute + '/skyfi/aircon/set_control_info'; this.basic_info = this.apiroute + '/skyfi/common/basic_info'; break;} case 'Faikin': case 'Faikout': { this.get_sensor_info = this.apiroute + '/aircon/get_sensor_info'; this.get_control_info = this.apiroute + '/aircon/get_control_info'; this.get_model_info = this.apiroute + '/aircon/get_model_info'; this.set_control_info = this.apiroute + '/aircon/set_control_info'; this.basic_info = this.apiroute + '/common/basic_info'; this.faikin_control = this.apiroute + '/control'; // Faikout JSON control endpoint break;} default: { this.get_sensor_info = this.apiroute + '/aircon/get_sensor_info'; this.get_control_info = this.apiroute + '/aircon/get_control_info'; this.get_model_info = this.apiroute + '/aircon/get_model_info'; this.set_control_info = this.apiroute + '/aircon/set_control_info'; this.basic_info = this.apiroute + '/common/basic_info'; break;} } this.log.debug('get_sensor_info %s', this.get_sensor_info); this.log.debug('Get_control_info %s', this.get_control_info); this.log.debug('Get_model_info %s', this.get_model_info); this.log.debug('Get_basic_info %s', this.basic_info); this.firmwareRevision = packageFile.version; this.log.info('Display Units: ', this.displayUnitsDescription[this.temperatureDisplayUnits]); // this.targetHeatingCoolingState = Characteristic.TargetHeatingCoolingState.AUTO; this.log.info('*****************************************************************'); this.log.info(' homebridge-daikin-local version ' + packageFile.version); this.log.info(' GitHub: https://github.com/cbrandlehner/homebridge-daikin-local '); this.log.info('*****************************************************************'); this.log.info('accessory name: ' + this.name); this.log.info('accessory ip: ' + this.apiIP); this.log.debug('system: ' + this.system); // Setting defaults for early response to improve HomeKit performance this.HeaterCooler_Active = Characteristic.Active.INACTIVE; this.HeaterCooler_SwingMode = Characteristic.SwingMode.SWING_DISABLED; this.HeaterCooler_CurrentHeaterCoolerState = Characteristic.CurrentHeaterCoolerState.IDLE; this.HeaterCooler_TargetHeaterCoolerState = Characteristic.TargetHeaterCoolerState.AUTO; this.HeaterCooler_CurrentTemperature = 21; this.HeaterCooler_CoolingTemperature = 21; this.HeaterCooler_HeatingTemperature = 21; this.HeaterCooler_CurrentHumidity = 40; this.Fan_Speed = 15; this.Fan_Status = 0; this.counter = 0; this.lastMode = 3; /* cooling */ this.lastFanSpeed = 10; /* Silent */ // description arrays this.modeDescription = ['off', 'Auto', 'Dehumidification', 'Cooling', 'Heating', 'unknown:5', 'Fan']; this.powerDescription = ['off', 'on']; this.FanService = new Service.Fan(this.fanName); this.heaterCoolerService = new Service.HeaterCooler(this.name); this.temperatureService = new Service.TemperatureSensor(this.name); this.humidityService = new Service.HumiditySensor(this.name); // Swing switch services for independent vertical/horizontal control (Faikout) this.verticalSwingName = config.verticalSwingName || 'Vertical Swing'; this.horizontalSwingName = config.horizontalSwingName || 'Horizontal Swing'; this.verticalSwingService = new Service.Switch(this.verticalSwingName, 'vertical-swing-switch'); this.verticalSwingService.setCharacteristic(Characteristic.ConfiguredName, this.verticalSwingName); this.horizontalSwingService = new Service.Switch(this.horizontalSwingName, 'horizontal-swing-switch'); this.horizontalSwingService.setCharacteristic(Characteristic.ConfiguredName, this.horizontalSwingName); this.Vertical_Swing = false; this.Horizontal_Swing = false; // Note: Optional characteristics are now handled via linked services // when disableFan=true to ensure they appear in HeaterCooler settings // Special modes switches - these toggle on/off // Note: Names stored in config for user identification this.econoModeName = config.econoModeName || 'Econo Mode'; this.powerfulModeName = config.powerfulModeName || 'Powerful Mode'; this.nightQuietModeName = config.nightQuietModeName || 'Night Quiet'; this.econoModeService = new Service.Switch(this.econoModeName, 'econo-mode-switch'); this.econoModeService.setCharacteristic(Characteristic.ConfiguredName, this.econoModeName); this.powerfulModeService = new Service.Switch(this.powerfulModeName, 'powerful-mode-switch'); this.powerfulModeService.setCharacteristic(Characteristic.ConfiguredName, this.powerfulModeName); this.nightQuietModeService = new Service.Switch(this.nightQuietModeName, 'night-quiet-switch'); this.nightQuietModeService.setCharacteristic(Characteristic.ConfiguredName, this.nightQuietModeName); // State for toggle modes this.Econo_Mode = false; this.Powerful_Mode = false; this.NightQuiet_Mode = false; // Config options for enabling these features this.enableEconoMode = !!config.enableEconoMode; this.enablePowerfulMode = !!config.enablePowerfulMode; this.enableNightQuietMode = !!config.enableNightQuietMode; // Swing switch config options (Faikout independent vertical/horizontal control) this.enableVerticalSwingSwitch = !!config.enableVerticalSwingSwitch; this.enableHorizontalSwingSwitch = !!config.enableHorizontalSwingSwitch; // Config options for fan controls in settings (v1.5.1) this.enableFanSpeedInSettings = config.enableFanSpeedInSettings !== undefined ? config.enableFanSpeedInSettings : true; this.enableOscillationInSettings = config.enableOscillationInSettings !== undefined ? config.enableOscillationInSettings : true; // Config options for temperature ranges (v1.5.2) this.minTemperature = config.minTemperature !== undefined ? config.minTemperature : 18; this.maxTemperature = config.maxTemperature !== undefined ? config.maxTemperature : 30; // Config option for quiet WebSocket logging (v1.5.2) this.quietWebSocketLogging = config.quietWebSocketLogging !== undefined ? config.quietWebSocketLogging : true; // WebSocket connection for Faikout (used for econo/powerful/quiet control) this.faikinWs = null; this.faikinWsReconnectTimer = null; this.faikinWsHeartbeat = null; this.faikinWsPendingCommands = []; } // --- BEGIN: OpenSSL / Agent helpers (added) --- /* eslint-disable no-bitwise */ /* Bitmask to (a) allow unsafe legacy renegotiation and (b) tolerate legacy servers. Using `|| 0` keeps this safe on builds where a constant might be missing. */ const SECURE_OPS = ((crypto.constants && crypto.constants.SSL_OP_ALLOW_UNSAFE_LEGACY_RENEGOTIATION) || 0) | ((crypto.constants && crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT) || 0); /* eslint-enable no-bitwise */ // Runtime check for OpenSSL 3 (Node 18+/20 typically link to OpenSSL 3.x) function isOpenSSL3() { return (process.versions.openssl || '').startsWith('3.'); } // Lazy singletons to avoid per-request Agent churn let LEGACY_AGENT = null; let DEFAULT_AGENT = null; let DEFAULT_HTTP_AGENT = null; // { for devices with old firmware using plain http URLs } function getLegacyAgent() { if (!LEGACY_AGENT) { LEGACY_AGENT = new https.Agent({ keepAlive: true, rejectUnauthorized: false, // device uses self-signed cert minVersion: 'TLSv1.2', maxVersion: 'TLSv1.2', secureOptions: SECURE_OPS, // If you see a cipher/security level error, consider: // ciphers: 'DEFAULT:@SECLEVEL=0', }); } return LEGACY_AGENT; } function getDefaultAgent() { if (!DEFAULT_AGENT) { DEFAULT_AGENT = new https.Agent({ keepAlive: true, rejectUnauthorized: false, }); } return DEFAULT_AGENT; } function getDefaultHttpAgent() { // { for devices with old firmware using plain http URLs as the code would crash trying to use https.Agent } if (!DEFAULT_HTTP_AGENT) { DEFAULT_HTTP_AGENT = new http.Agent({ keepAlive: true, }); } return DEFAULT_HTTP_AGENT; } // --- END: OpenSSL / Agent helpers --- Daikin.prototype = { parseResponse, daikinSpeedToRaw, rawToDaikinSpeed, sendGetRequest(path, callback, options) { this.log.debug('attempting request: path: %s', path); this._queueGetRequest(path, callback, options || {}); }, _queueGetRequest(path, callback, options) { const method = options.skipQueue ? 'prepend' : 'append'; this.log.debug(`queuing (${method}) request: path: %s`, path); this.queue[method](done => { this.log.debug('executing queued request: path: %s', path); this._doSendGetRequest(path, (error, response) => { if (error) { // this.log.error('ERROR: Queued request to %s returned error %s', path, error); if (error.code === 'ECONNRESET') { this.log.debug('requeueing request after econnreset'); options.skipQueue = 'prepend'; this._queueGetRequest(path, callback, options || {}); } done(); return; } this.log.debug('queued request finished: path: %s', path); // actual response callback if (!(callback === undefined)) callback(response); done(); }, options); }); }, _doSendGetRequest(path, callback, options) { // Preserve old cache behavior if present if (this._serveFromCache && this._serveFromCache(path, callback, options)) return; this.log.debug('_doSendGetRequest: requesting from API: path: %s', path); // Handle both throttle styles: { plugin() {..} } or a raw function(req)=>req const throttlePlugin = (this.throttle && typeof this.throttle.plugin === 'function') ? this.throttle.plugin() : (typeof this.throttle === 'function' ? this.throttle : r => r); let request = superagent .get(path) .retry(this.retries) // default retry count .timeout({ response: this.response, // ms to first byte deadline: this.deadline, // total ms to finish }) .use(throttlePlugin) .set('User-Agent', 'superagent') .set('Host', this.apiIP); if (this.uuid !== '') { request = request.set('X-Daikin-uuid', this.uuid); } // --- BEGIN: protocol-aware agent selection (changed) --- let urlProtocol = 'https:'; try { urlProtocol = new URL(path).protocol; } catch { // fallback: if parsing fails, assume https for safety urlProtocol = 'https:'; } if (urlProtocol === 'https:') { if (isOpenSSL3()) { // Node linked against OpenSSL 3: enable legacy reneg + lock to TLS1.2 request = request.agent(getLegacyAgent()); } else if (typeof request.disableTLSCerts === 'function') { // OpenSSL 1.1.1 path (legacy behavior) request = request.disableTLSCerts(); } else { // Some superagent builds dropped disableTLSCerts(); use an agent fallback request = request.agent(getDefaultAgent()); } } else { // http: use an http.Agent (do NOT use https.Agent for plain http URLs) request = request.agent(getDefaultHttpAgent()); } // --- END: protocol-aware agent selection --- // Use end(...) to get a single error/result callback and maintain compatibility. request.end((error, response) => { if (error) { if (error.timeout) {/* timed out */} else if (error.code === 'ECONNRESET') { this.log.debug('_doSendGetRequest: eConnreset filtered'); } else { this.log.error('_doSendGetRequest: ERROR: API request to %s returned error %s', path, error); } return callback && callback(error); } // Prefer text when available (keeps compatibility with parseResponse callers) const body = response && (response.text ?? (typeof response.body === 'string' ? response.body : JSON.stringify(response.body))); try { if (this.cache && typeof this.cache.set === 'function') { this.log.debug('_doSendGetRequest: set cache: path: %s', path); this.cache.set(path, body); } } catch (error) { this.log.debug('_doSendGetRequest: cache set failed: %s', error.message || error); } this.log.debug('_doSendGetRequest: response from API: %s', body); return callback && callback(null, body); }); }, _serveFromCache(path, callback, options) { this.log.debug('requesting from cache: path: %s', path); if (options.skipCache) { this.log.debug('cache SKIP: path: %s', path); return false; } if (!this.cache.has(path)) { this.log.debug('cache MISS: path: %s', path); return false; } if (this.cache.expired(path)) { this.log.debug('cache EXPIRED: path: %s', path); return false; } const cachedResponse = this.cache.get(path); if (cachedResponse === undefined) { this.log.debug('cache EMPTY: path: %s', path); return false; } this.log.debug('cache HIT: path: %s', path); this.log.debug('responding from cache: %s', cachedResponse); if (!(callback === undefined)) callback(null, cachedResponse); return true; }, sendFaikinControl(controlData, callback) { this.log.debug('sendFaikinControl: Sending control command to Faikout: %s', JSON.stringify(controlData)); const path = this.apiroute + '/control'; // Determine protocol for agent selection let urlProtocol = 'https:'; try { urlProtocol = new URL(path).protocol; } catch { urlProtocol = 'https:'; } let request = superagent .post(path) .send(controlData) .set('Content-Type', 'application/json') .set('User-Agent', 'superagent') .set('Host', this.apiIP) .retry(this.retries) .timeout({ response: this.response, deadline: this.deadline, }); if (this.uuid !== '') { request = request.set('X-Daikin-uuid', this.uuid); } // Apply appropriate agent based on protocol if (urlProtocol === 'https:') { if (isOpenSSL3()) { request = request.agent(getLegacyAgent()); } else if (typeof request.disableTLSCerts === 'function') { request = request.disableTLSCerts(); } else { request = request.agent(getDefaultAgent()); } } else { request = request.agent(getDefaultHttpAgent()); } request.end((error, response) => { if (error) { this.log.warn('sendFaikinControl: JSON control endpoint failed (%s), trying WebSocket fallback', error.message); // Fallback to WebSocket for Faikout S21 protocol (econo/powerful/quiet modes) this.sendFaikinWebSocketCommand(controlData, callback); return; } const body = response && (response.text ?? (typeof response.body === 'string' ? response.body : JSON.stringify(response.body))); this.log.debug('sendFaikinControl: response from API: %s', body); return callback && callback(null, body); }); }, sendFaikinControlFallback(controlData, callback) { this.log.info('sendFaikinControlFallback: Converting JSON to traditional Daikin API: %s', JSON.stringify(controlData)); // Get current status first, then modify it with our changes this.sendGetRequest(this.get_control_info, body => { let query = body.replace(/,/g, '&'); // Convert JSON control data to query string parameters if (controlData.power !== undefined) { query = query.replace(/pow=[01]/, `pow=${controlData.power ? '1' : '0'}`); } if (controlData.mode !== undefined) { const modeMap = { H: '4', C: '3', A: '1', D: '2', F: '6', }; const mode = modeMap[controlData.mode] || controlData.mode; query = query.replace(/mode=[01234567]/, `mode=${mode}`); } if (controlData.temp !== undefined) { const temp = Number.parseFloat(controlData.temp).toFixed(1); query = query.replace(/stemp=[\d.]+/, `stemp=${temp}`); query = query.replace(/dt3=[\d.]+/, `dt3=${temp}`); } if (controlData.fan !== undefined) { query = query.replace(/f_rate=[01234567ABQ]/, `f_rate=${controlData.fan}`); query = query.replace(/b_f_rate=[01234567ABQ]/, `b_f_rate=${controlData.fan}`); } if (controlData.swingh !== undefined || controlData.swingv !== undefined) { // For traditional API, use f_dir: 0=no swing, 1=vertical, 2=horizontal, 3=both const swingH = controlData.swingh; const swingV = controlData.swingv; let swingMode = '0'; if (swingH && swingV) swingMode = '3'; else if (swingV) swingMode = '1'; else if (swingH) swingMode = '2'; query = query.replace(/f_dir=[0123]/, `f_dir=${swingMode}`); query = query.replace(/b_f_dir=[0123]/, `b_f_dir=${swingMode}`); } if (controlData.econo !== undefined) { // Add en_economode parameter if not present in response if (query.includes('en_economode=')) { query = query.replace(/en_economode=[01]/, `en_economode=${controlData.econo ? '1' : '0'}`); } else { query += `&en_economode=${controlData.econo ? '1' : '0'}`; } } if (controlData.powerful !== undefined) { // Add en_powerful parameter if not present in response if (query.includes('en_powerful=')) { query = query.replace(/en_powerful=[01]/, `en_powerful=${controlData.powerful ? '1' : '0'}`); } else { query += `&en_powerful=${controlData.powerful ? '1' : '0'}`; } } this.log.info('sendFaikinControlFallback: Using traditional API query: %s', query); this.sendGetRequest(this.set_control_info + '?' + query, response => { callback && callback(null, response); }, {skipCache: true, skipQueue: true}); }, {skipCache: true}); }, // WebSocket connection management for Faikout connectFaikinWebSocket() { if (this.faikinWs && this.faikinWs.readyState === WebSocket.OPEN) { this.log.debug('connectFaikinWebSocket: Already connected'); return; } // Clear any existing reconnect timer if (this.faikinWsReconnectTimer) { clearTimeout(this.faikinWsReconnectTimer); this.faikinWsReconnectTimer = null; } // Determine WebSocket URL (ws:// or wss://) const protocol = this.apiroute.startsWith('https') ? 'wss://' : 'ws://'; const wsUrl = `${protocol}${this.apiIP}/status`; const logMethod = this.quietWebSocketLogging ? 'debug' : 'info'; this.log[logMethod]('connectFaikinWebSocket: Connecting to %s', wsUrl); try { this.faikinWs = new WebSocket(wsUrl, { rejectUnauthorized: false, // Allow self-signed certificates }); this.faikinWs.on('open', () => { this.log[logMethod]('connectFaikinWebSocket: WebSocket connected to Faikout'); if (this.quietWebSocketLogging) { this.log.info('connectFaikinWebSocket: Quiet WebSocket logging enabled - status updates will use debug level'); } else { this.log.info('connectFaikinWebSocket: Verbose WebSocket logging enabled - all status updates will be logged'); } // Start heartbeat to receive status updates (required by Faikout) // Based on Faikout web UI: setInterval(function() {if(!ws)c();else ws.send('');}, 1000); this.faikinWsHeartbeat = setInterval(() => { if (this.faikinWs && this.faikinWs.readyState === 1) { this.faikinWs.send(''); // Send empty heartbeat message this.log.debug('connectFaikinWebSocket: Sent heartbeat to Faikout'); } }, 1000); // Send any pending commands if (this.faikinWsPendingCommands.length > 0) { this.log.debug('connectFaikinWebSocket: Sending %d pending commands', this.faikinWsPendingCommands.length); while (this.faikinWsPendingCommands.length > 0) { const cmd = this.faikinWsPendingCommands.shift(); this.sendFaikinWebSocketCommand(cmd.data, cmd.callback); } } }); this.faikinWs.on('message', (data) => { try { const message = JSON.parse(data.toString()); // Only log WebSocket messages if verbose logging is enabled if (!this.quietWebSocketLogging) { this.log.info('connectFaikinWebSocket: <<<< Received status from Faikout: %s', JSON.stringify(message)); } else { this.log.debug('connectFaikinWebSocket: <<<< Received status from Faikout: %s', JSON.stringify(message)); } // Update local state based on received status from Faikout (including rejections) if (message.econo !== undefined) { const econoState = !!message.econo; const oldState = this.Econo_Mode; // Only log when state actually changes if (oldState !== econoState) { if (!this.quietWebSocketLogging) { this.log.info('connectFaikinWebSocket: Econo mode: %s → %s', oldState, econoState); } else { this.log.debug('connectFaikinWebSocket: Econo mode: %s → %s', oldState, econoState); } } this.Econo_Mode = econoState; if (this.enableEconoMode && this.econoModeService && oldState !== econoState) { this.econoModeService.updateCharacteristic(Characteristic.On, this.Econo_Mode); if (!this.quietWebSocketLogging) { this.log.info('connectFaikinWebSocket: ✅ Updated Econo switch to: %s', this.Econo_Mode); } else { this.log.debug('connectFaikinWebSocket: ✅ Updated Econo switch to: %s', this.Econo_Mode); } } } if (message.powerful !== undefined) { const powerfulState = !!message.powerful; const oldState = this.Powerful_Mode; // Only log when state actually changes if (oldState !== powerfulState) { if (!this.quietWebSocketLogging) { this.log.info('connectFaikinWebSocket: Powerful mode: %s → %s', oldState, powerfulState); } else { this.log.debug('connectFaikinWebSocket: Powerful mode: %s → %s', oldState, powerfulState); } } this.Powerful_Mode = powerfulState; if (this.enablePowerfulMode && this.powerfulModeService && oldState !== powerfulState) { this.powerfulModeService.updateCharacteristic(Characteristic.On, this.Powerful_Mode); if (!this.quietWebSocketLogging) { this.log.info('connectFaikinWebSocket: ✅ Updated Powerful switch to: %s', this.Powerful_Mode); } else { this.log.debug('connectFaikinWebSocket: ✅ Updated Powerful switch to: %s', this.Powerful_Mode); } } } // Night Quiet mode is controlled by fan speed 'Q', not the 'quiet' field // The 'quiet' field controls outdoor unit quiet mode (different feature) if (message.fan !== undefined) { const nightQuietState = (message.fan === 'Q'); const oldState = this.NightQuiet_Mode; // Only log when state actually changes if (oldState !== nightQuietState) { if (!this.quietWebSocketLogging) { this.log.info('connectFaikinWebSocket: Night Quiet mode: %s → %s (fan: %s)', oldState, nightQuietState, message.fan); } else { this.log.debug('connectFaikinWebSocket: Night Quiet mode: %s → %s (fan: %s)', oldState, nightQuietState, message.fan); } } this.NightQuiet_Mode = nightQuietState; if (this.enableNightQuietMode && this.nightQuietModeService && oldState !== nightQuietState) { this.nightQuietModeService.updateCharacteristic(Characteristic.On, this.NightQuiet_Mode); if (!this.quietWebSocketLogging) { this.log.info('connectFaikinWebSocket: ✅ Updated Night Quiet switch to: %s', this.NightQuiet_Mode); } else { this.log.debug('connectFaikinWebSocket: ✅ Updated Night Quiet switch to: %s', this.NightQuiet_Mode); } } } } catch (error) { this.log.warn('connectFaikinWebSocket: Error parsing message: %s', error.message); this.log.warn('connectFaikinWebSocket: Raw data was: %s', data.toString()); } }); this.faikinWs.on('error', (error) => { this.log.warn('connectFaikinWebSocket: WebSocket error: %s', error.message); }); this.faikinWs.on('close', () => { const logMethod = this.quietWebSocketLogging ? 'debug' : 'info'; this.log[logMethod]('connectFaikinWebSocket: WebSocket closed, will reconnect in 5 seconds'); // Clear heartbeat timer if (this.faikinWsHeartbeat) { clearInterval(this.faikinWsHeartbeat); this.faikinWsHeartbeat = null; } this.faikinWs = null; // Reconnect after 5 seconds this.faikinWsReconnectTimer = setTimeout(() => { this.connectFaikinWebSocket(); }, 5000); }); } catch (error) { this.log.error('connectFaikinWebSocket: Failed to create WebSocket: %s', error.message); // Retry connection after 10 seconds this.faikinWsReconnectTimer = setTimeout(() => { this.connectFaikinWebSocket(); }, 10_000); } }, sendFaikinWebSocketCommand(controlData, callback) { if (!this.faikinWs || this.faikinWs.readyState !== WebSocket.OPEN) { this.log.debug('sendFaikinWebSocketCommand: WebSocket not connected, queuing command'); this.faikinWsPendingCommands.push({data: controlData, callback}); this.connectFaikinWebSocket(); return; } const message = JSON.stringify(controlData); const logMethod = this.quietWebSocketLogging ? 'debug' : 'info'; this.log[logMethod]('sendFaikinWebSocketCommand: >>>> Sending to Faikout: %s', message); try { this.faikinWs.send(message, (error) => { if (error) { this.log.error('sendFaikinWebSocketCommand: Error sending command: %s', error.message); if (callback) callback(error); } else { this.log[logMethod]('sendFaikinWebSocketCommand: Command sent successfully, waiting for Faikout response...'); if (callback) callback(null); } }); } catch (error) { this.log.error('sendFaikinWebSocketCommand: Exception sending command: %s', error.message); if (callback) callback(error); } }, closeFaikinWebSocket() { if (this.faikinWsReconnectTimer) { clearTimeout(this.faikinWsReconnectTimer); this.faikinWsReconnectTimer = null; } if (this.faikinWsHeartbeat) { clearInterval(this.faikinWsHeartbeat); this.faikinWsHeartbeat = null; } if (this.faikinWs) { const logMethod = this.quietWebSocketLogging ? 'debug' : 'info'; this.log[logMethod]('closeFaikinWebSocket: Closing WebSocket connection'); this.faikinWs.close(); this.faikinWs = null; } }, getActive(callback) { this.sendGetRequest(this.get_control_info, body => { const responseValues = this.parseResponse(body); this.log.debug('getActive: Power is: %s, Mode is %s', responseValues.pow, responseValues.mode); let HomeKitState = '0'; if (responseValues.mode === '6' || responseValues.mode === '2') // If AC is in Fan-mode or Dehumidification-mode then show AC OFF in HomeKit HomeKitState = '0'; else if (responseValues.pow === '1') HomeKitState = '1'; // Power is ON and the device is neither in Fan-mode nor Humidity-mode else HomeKitState = '0'; // Power is OFF if (!(callback === undefined)) callback(null, HomeKitState === '1' ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE); }); }, getActiveFV(callback) { // FV 210510: Wrapper for service call to early return const counter = ++this.counter; this.log.debug('getActiveFV: early callback with cached Active: %s (%d).', this.HeaterCooler_Active, counter); if (!(callback === undefined)) callback(null, this.HeaterCooler_Active); this.getActive((error, HomeKitState) => { this.HeaterCooler_Active = HomeKitState; this.heaterCoolerService.getCharacteristic(Characteristic.Active).updateValue(this.HeaterCooler_Active); this.log.debug('getActiveFV: update Active: %s (%d).', this.HeaterCooler_Active, counter); }); }, setActive(power, callback) { this.sendGetRequest(this.get_control_info, body => { const responseValues = this.parseResponse(body); this.log.info('setActive: Power is %s, Mode is %s. Going to change power to %s.', responseValues.pow, responseValues.mode, power); let query = body.replace(/,/g, '&').replace(/pow=[01]/, `pow=${power}`); if (responseValues.mode === '6' || responseValues.mode === '2' || responseValues.mode === '1' || responseValues.mode === '0') {// If AC is in Fan-mode, or an Humidity-mode then use the default mode. switch (this.defaultMode) { case '1': { // Auto this.log.warn('Auto'); query = query .replace(/mode=[01234567]/, `mode=${this.defaultMode}`) .replace(/stemp=--/, `stemp=${responseValues.dt7}`) .replace(/dt3=--/, `dt3=${responseValues.dt7}`) .replace(/shum=--/, `shum=${'0'}`); break;} case '3': { // COOL query = query .replace(/mode=[01234567]/, `mode=${this.defaultMode}`) .replace(/stemp=--/, `stemp=${responseValues.dt7}`) .replace(/dt3=--/, `dt3=${responseValues.dt7}`) .replace(/shum=--/, `shum=${'0'}`); break;} case '4': { // HEAT query = query .replace(/mode=[01234567]/, `mode=${this.defaultMode}`) .replace(/stemp=--/, `stemp=${responseValues.dt5}`) .replace(/dt3=--/, `dt3=${responseValues.dt5}`) .replace(/shum=--/, `shum=${'0'}`); break;} default: } query = query .replace(/mode=[01234567]/, `mode=${this.defaultMode}`) .replace(/stemp=--/, `stemp=${'25.0'}`) .replace(/dt3=--/, `dt3=${'25.0'}`) .replace(/shum=--/, `shum=${'0'}`); } this.HeaterCooler_Active = power; // FV210510 updating Active Cache this.log.debug('setActive: update Active: %s.', this.HeaterCooler_Active); // FV210510 this.sendGetRequest(this.set_control_info + '?' + query, _response => { this.HeaterCooler_Active = power; // FV210510 updating Active Cache this.log.debug('setActive: update Active: %s.', this.HeaterCooler_Active); // FV210510 if (!(callback === undefined)) callback(); if (power === '0') { this.lastFanSpeed = this.Fan_Speed; this.setFanSpeed(0); } }, {skipCache: true, skipQueue: true}); }, {skipCache: true}); }, getSwingMode(callback) { this.sendGetRequest(this.get_control_info, body => { const responseValues = this.parseResponse(body); if (this.isFaikin) { // Faikout uses separate swingh and swingv booleans const swingH = responseValues.swingh === '1' || responseValues.swingh === 'true' || responseValues.swingh === true; const swingV = responseValues.swingv === '1' || responseValues.swingv === 'true' || responseValues.swingv === true; this.log.debug('getSwingMode (Faikout): swingh=%s, swingv=%s, swingMode config=%s', swingH, swingV, this.swingMode); // Update cached swing states for the separate switches this.Vertical_Swing = swingV; this.Horizontal_Swing = swingH; // Determine HomeKit swing state based on swingMode config // swingMode '1' = vertical, '2' = horizontal, '3' = 3D (both) let isEnabled = false; if (this.swingMode === '1') { isEnabled = swingV; } else if (this.swingMode === '2') { isEnabled = swingH; } else { // '3' or default: enabled if both are on isEnabled = swingH && swingV; } callback(null, isEnabled ? Characteristic.SwingMode.SWING_ENABLED : Characteristic.SwingMode.SWING_DISABLED); } else { // Traditional Daikin f_dir values: // 0 - No swing // 1 - Vertical swing // 2 - Horizontal swing // 3 - 3D swing this.log.debug('getSwingMode: swing mode is: %s. 0=No swing, 1=Vertical swing, 2=Horizontal swing, 3=3D swing.', responseValues.f_dir); this.log.debug('getSwingMode: swing mode for HomeKit is: %s. 0=Disabled, 1=Enabled', responseValues.f_dir === '0' ? Characteristic.SwingMode.SWING_ENABLED : Characteristic.SwingMode.SWING_DISABLED); callback(null, responseValues.f_dir === '0' ? Characteristic.SwingMode.SWING_DISABLED : Characteristic.SwingMode.SWING_ENABLED); } }); }, getSwingModeFV(callback) { // FV 210510: Wrapper for service call to early return const counter = ++this.counter; this.log.debug('getSwingModeFV: early callback with cached SwingMode: %s (%d).', this.HeaterCooler_SwingMode, counter); callback(null, this.HeaterCooler_SwingMode); this.getSwingMode((error, HomeKitState) => { this.HeaterCooler_SwingMode = HomeKitState; this.heaterCoolerService.getCharacteristic(Characteristic.SwingMode).updateValue(this.HeaterCooler_SwingMode); // FV210504 this.log.debug('getSwingModeFV: update SwingMode: %s (%d).', this.HeaterCooler_SwingMode, counter); }); }, setSwingMode(swing, callback) { if (this.isFaikin) { // Faikout uses separate swingh and swingv booleans // Use swingMode config to determine which swing to enable: // '1' = vertical only, '2' = horizontal only, '3' = 3D (both) const enableSwing = (swing !== Characteristic.SwingMode.SWING_DISABLED); let swingH = false; let swingV = false; if (enableSwing) { if (this.swingMode === '1') { swingV = true; } else if (this.swingMode === '2') { swingH = true; } else { // '3' or default: enable both (3D) swingH = true; swingV = true; } } const controlData = { swingh: swingH, swingv: swingV, }; this.log.info( 'setSwingMode (Faikout): HomeKit requested swing mode: %s, swingMode config: %s (swingh=%s, swingv=%s)', swing, this.swingMode, swingH, swingV, ); this.HeaterCooler_SwingMode = swing; this.log.debug('setSwingMode: update SwingMode: %s.', this.HeaterCooler_SwingMode); // Update cached states for separate swing switches this.Vertical_Swing = swingV; this.Horizontal_Swing = swingH; if (this.enableVerticalSwingSwitch) { this.verticalSwingService.getCharacteristic(Characteristic.On).updateValue(swingV); } if (this.enableHorizontalSwingSwitch) { this.horizontalSwingService.getCharacteristic(Characteristic.On).updateValue(swingH); } this.sendFaikinControl(controlData, () => { this.HeaterCooler_SwingMode = swing; this.log.debug('setSwingMode: confirmed SwingMode: %s.', this.HeaterCooler_SwingMode); callback(); }); } else { // Traditional Daikin API this.sendGetRequest(this.get_control_info, body => { this.log.info('setSwingMode: HomeKit requested swing mode: %s', swing); if (swing !== Characteristic.SwingMode.SWING_DISABLED) swing = this.swingMode; let query = body.replace(/,/g, '&').replace(/f_dir=[0123]/, `f_dir=${swing}`); query = query.replace(/,/g, '&').replace(/b_f_dir=[0123]/, `b_f_dir=${swing}`); this.log.debug('setSwingMode: swing mode: %s, query is: %s', swing, query); this.HeaterCooler_SwingMode = swing; // FV210510 update cache this.log.debug('setSwingMode: update SwingMode: %s.', this.HeaterCooler_SwingMode); // FV210510 this.sendGetRequest(this.set_control_info + '?' + query, _response => { this.HeaterCooler_SwingMode = swing; // FV210510 update cache this.log.debug('setSwingMode: update SwingMode: %s.', this.HeaterCooler_SwingMode); // FV210510 callback(); }, {skipCache: true, skipQueue: true}); }, {skipCache: true}); } }, // Separate Vertical Swing switch (Faikout only) getVerticalSwing: function (callback) { this.sendGetRequest(this.get_control_info, body => { const responseValues = this.parseResponse(body); const swingV = responseValues.swingv === '1' || responseValues.swingv === 'true' || responseValues.swingv === true; this.log.debug('getVerticalSwing (Faikout): swingv=%s', swingV); callback(null, swingV); }); }, getVerticalSwingFV: function (callback) { const counter = ++this.counter; this.log.debug('getVerticalSwingFV: early callback with cached state: %s (%d).', this.Vertical_Swing, counter); callback(null, this.Vertical_Swing); this.getVerticalSwing((error, state) => { this.Vertical_Swing = state; this.verticalSwingService.getCharacteristic(Characteristic.On).updateValue(this.Vertical_Swing); this.log.debug('getVerticalSwingFV: update VerticalSwing: %s (%d).', this.Vertical_Swing, counter); }); }, setVerticalSwing: function (value, callback) { this.log.info('setVerticalSwing (Faikout): HomeKit requested vertical swing %s.', value ? 'ON' : 'OFF'); this.Vertical_Swing = value; const controlData = {swingv: value}; this.sendFaikinControl(controlData, () => { this.log.debug('setVerticalSwing: confirmed VerticalSwing: %s.', this.Vertical_Swing); // Update the main oscillation toggle to reflect the combined state this._updateMainSwingMode(); if (callback) callback(); }); }, // Separate Horizontal Swing switch (Faikout only) getHorizontalSwing: function (callback) { this.sendGetRequest(this.get_control_info, body => { const responseValues = this.parseResponse(body); const swingH = responseValues.swingh === '1' || responseValues.swingh === 'true' || responseValues.swingh === true; this.log.debug('getHorizontalSwing (Faikout): swingh=%s', swingH); callback(null, swingH); }); }, getHorizontalSwingFV: function (callback) { const counter = ++this.counter; this.log.debug('getHorizontalSwingFV: early callback with cached state: %s (%d).', this.Horizontal_Swing, counter); callback(null, this.Horizontal_Swing); this.getHorizontalSwing((error, state) => { this.Horizontal_Swing = state; this.horizontalSwingService.getCharacteristic(Characteristic.On).updateValue(this.Horizontal_Swing); this.log.debug('getHorizontalSwingFV: update HorizontalSwing: %s (%d).', this.Horizontal_Swing, counter); }); }, setHorizontalSwing: function (value, callback) { this.log.info('setHorizontalSwing (Faikout): HomeKit requested horizontal swing %s.', value ? 'ON' : 'OFF'); this.Horizontal_Swing = value; const controlData = {swingh: value}; this.sendFaikinControl(controlData, () => { this.log.debug('setHorizontalSwing: confirmed HorizontalSwing: %s.', this.Horizontal_Swing); // Update the main oscillation toggle to reflect the combined state this._updateMainSwingMode(); if (callback) callback(); }); }, // Helper: update the main SwingMode characteristic after individual swing changes _updateMainSwingMode: function () { let isEnabled = false; if (this.swingMode === '1') { isEnabled = this.Vertical_Swing; } else if (this.swingMode === '2') { isEnabled = this.Horizontal_Swing; } else { isEnabled = this.Vertical_Swing && this.Horizontal_Swing; } this.HeaterCooler_SwingMode = isEnabled ? Characteristic.SwingMode.SWING_ENABLED : Characteristic.SwingMode.SWING_DISABLED; // Update on both HeaterCooler and Fan services if they have SwingMode this.heaterCoolerService.getCharacteristic(Characteristic.SwingMode).updateValue(this.HeaterCooler_Swin