homebridge-daikin-local
Version:
daikin plugin for homebridge: https://github.com/homebridge/homebridge
1,209 lines (1,031 loc) • 97.9 kB
JavaScript
/* 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