UNPKG

iobroker.smartcontrol

Version:

Control devices smarter: by grouping, including triggers like motion, opening window, etc. and set target devices accordingly

902 lines (750 loc) 108 kB
'use strict'; /** * ioBroker Smart Control Adapter * * @github https://github.com/iobroker-community-adapters/ioBroker.smartcontrol * @forum https://forum.iobroker.net/topic/36728/ * @author Mic-M <https://github.com/Mic-M/ioBroker.smartcontrol> * @license MIT * * Developer Notes: * - A few ASCII blocks are being used for ease of code section identification. I've used * http://patorjk.com/software/taag/#p=display&v=3&f=ANSI%20Regular&t=smart%20control%20adapter */ // ioBroker Adapter core module const utils = require('@iobroker/adapter-core'); /** * Main Adapter Class * @class SmartControl */ class SmartControl extends utils.Adapter { /** * Constructor * @param {Partial<utils.AdapterOptions>} [options={}] Adapter Options */ constructor(options) { super( {...options, name: 'smartcontrol'} ); // to access the object's parent this.on('ready', this._asyncOnReady.bind(this)); this.on('stateChange', this._asyncOnStateChange.bind(this)); this.on('message', this._asyncOnMessage.bind(this)); this.on('unload', this._onUnload.bind(this)); // adapter-specific objects being set here for global availability in adapter.x // We are using 'x' to avoid potential namespace conflicts this.x = { /** * Load Modules * VS Code: For all NPM modules, open console, change dir to "C:\iobroker\node_modules\ioBroker.<AdapterName>", * and execute "npm install <module name>", ex: npm install suncalc2 */ constants: require('./lib/constants.js'), // Adapter constants helper: require('./lib/helper.js')(this), // Generic JavaScript and ioBroker Adapter methods - https://forum.iobroker.net/post/484620 Trigger: require('./lib/trigger-class.js'), // Class for Triggers and Target devices handling mSuncalc: require('suncalc2'), // https://github.com/andiling/suncalc2 mSchedule: require('node-schedule'), // https://github.com/node-schedule/node-schedule axios: require('axios').default, // {object} timers - All timer objects. timersZoneOn: {}, // for option "onAfter" in Zones table timersZoneOff: {}, // for option "offAfter" in Zones table timersMimicMotionTrigger: {}, // for "motion linked trigger", to turn off after x sec if no motion timersZoneDeviceOnDelay: {}, // for Zones devices, target overwrite delay // {object} schedules - All schedules (node-schedule) // NOTE: different level as in 'timers', key value holds the schedule object. schedules: { midnight: null, // more schedule keys added later, as key: name of row of tableTriggerTimes }, // Motion trigger should not set timeout if target device was turned on previously without a motion trigger. // Ref. https://forum.iobroker.net/post/433871 | https://forum.iobroker.net/post/437803 motionTriggeredDevices: [], // ioBroker system configuration object (the common object of it), set in _asyncOnReady() // Has key names like: city, country, longitude, latitude, language, tempUnit, currency, dateFormat, isFloatComma, licenseConfirmed, defaultHistory, activeRepo, diag systemConfig: {}, // Keep triggerName and timestamp for checking for limitTriggerInterval to not switch more than x secs onStateChangeTriggers: {}, // Instances of Trigger class with trigger name as key name, and the class instance as value triggers: {}, // Zones Status - currently switched on/off, e.g.: {'Bathroom First Floor': true, 'Dining Area':false} // Initialized in _asyncOnReady() with false. zonesIsOn: {}, // Logs of smartcontrol.x.info.log.xxxxxx.json jsonLogs: {}, }; } /** * Called once ioBroker databases are connected and adapter received configuration. */ async _asyncOnReady() { try { // Get system configuration const sysConf = await this.getForeignObjectAsync('system.config'); if (sysConf && sysConf.common) { this.x.systemConfig = sysConf.common; } else { throw(`ioBroker system configuration not found.`); } // Get room and function enumerations (for tableTargetEnums) and set to tableTargetDevices. Also update tableZones. const convertEnums = await this._asyncConvertEnumTargetsToTargetDevices(); if (!convertEnums) { this.log.error(`[User Error (Anwenderfehler)] - Tab 'TARGETS', table 'Enum functions': active row(s) found, but no corresponding states found for the enum function(s) selected.`); this.log.error(`Please check the previous warn log for details and then correct your configuration accordingly. You must fix these issue(s) to use this adapter.`); this.setState('info.connection', false, true); // change to yellow return; // Go out. } // Warning if latitude / longitude not defined if (!this.x.systemConfig.latitude || !this.x.systemConfig.longitude) { this.log.warn('Latitude/Longitude is not defined in your ioBroker main configuration, so you will not be able to use Astro functionality for schedules!'); } /** * For testing: create test states */ await this.x.helper.asyncCreateStates(this.x.constants.testStates); /** * Create smartcontrol.x.info.astroTimes states. */ if (this.x.systemConfig.latitude && this.x.systemConfig.longitude) { const statesToBeCreated = []; for (let i = 0; i < this.x.constants.astroTimes.length; i++) { const lpAstro = this.x.constants.astroTimes[i]; const statePathMain = `${this.namespace}.info.astroTimes.${this.x.helper.zeroPad(i+1, 2)}_${lpAstro}`; const commonMain = { name: `${lpAstro} – ${this.x.constants.astroTimesGerman[i]}`, type: 'string', read: true, write: false, role: 'value', def: '' }; statesToBeCreated.push({statePath:statePathMain, commonObject:commonMain}); // Also, create timestamps const statePathTs = `${this.namespace}.info.astroTimes.timeStamps.${lpAstro}`; const commonTs = { name: `${lpAstro} – ${this.x.constants.astroTimesGerman[i]}`, type: 'number', read: true, write: false, role: 'value', def: 0 }; statesToBeCreated.push({statePath:statePathTs, commonObject:commonTs}); } // Now create the states const createStatesResult = await this.x.helper.asyncCreateStates(statesToBeCreated); if (!createStatesResult) throw (`Certain error(s) occurred in asyncCreateStates().`); // Set current astro values await this._asyncOnReady_asyncRefreshAstroStates(); } /** * Create objects: * - smartcontrol.x.info.log.zoneActivations.json * - smartcontrol.x.info.log.switchedTargetDevices.json */ const toCreate = [ {id:'zoneActivations', name:'JSON of recent zone activations'}, {id:'switchedTargetDevices', name: 'JSON of recent switched target devices'} ]; for (const lpObj of toCreate) { await this.x.helper.asyncCreateStates({ statePath: `${this.namespace}.info.log.${lpObj.id}.json`, commonObject: {name:lpObj.name, type:'string', read:true, write:false, role:'json', def:'' } }); // Note: above will not overwrite existing states and values. // Now get state value and set into global variable const lpState = await this.getStateAsync(`info.log.${lpObj.id}.json`); if (lpState && lpState.val && typeof lpState.val == 'string') { this.x.jsonLogs[lpObj.id] = JSON.parse(lpState.val); } else { this.log.debug(`State value of 'info.log.${lpObj.id}.json' is empty.`); } } /** * Create smartcontrol.x.userstates states */ const statePathsUsed = []; // to delete unused states for (const lpRow of this.config.tableTriggerDevices) { const statePath = `userstates.${lpRow.stateId}`; const statePathFull = `${this.namespace}.${statePath}`; if(lpRow.userState) { // We also create states for inactive rows, since user likely deactivates temporarily. if (this.x.helper.isStateIdValid(`${this.namespace}.${statePath}`)) { const stateCommonString = {name:`User trigger '${lpRow.name}' of 'Other Devices' in Zones table`, type:'string', read:true, write:true, role:'state', def:'' }; const stateCommonBoolean = {name:`User trigger '${lpRow.name}' of 'Other Devices' in Zones table`, type:'boolean', read:true, write:true, role:'state', def:false }; const lpStateObject = await this.getObjectAsync(statePath); if (!lpStateObject) { // State does not exist let stateObj = {}; if(lpRow.stateVal=='true' || lpRow.stateVal=='false') { stateObj = {statePath:statePath, commonObject:stateCommonBoolean}; } else { stateObj = {statePath:statePath, commonObject:stateCommonString}; } await this.x.helper.asyncCreateStates(stateObj, false); // Create state this.log.debug(`User state '${statePathFull}' created per option table 'Other Devices'.`); } else { // State exists. Now let's check if the "type" changed from boolean to string or vice versa. const isStateConfValBoolean = (lpRow.stateVal=='true' || lpRow.stateVal=='false') ? true : false; const isStateRealValBoolean = (lpStateObject.common.type == 'boolean' || lpStateObject.common.type == 'switch') ? true : false; if (isStateConfValBoolean != isStateRealValBoolean) { const newCommon = (isStateConfValBoolean) ? stateCommonBoolean : stateCommonString; this.log.debug(`User state '${statePathFull}' - state type changed, so delete old state and create new one.`); await this.delObjectAsync(statePath); // Delete state. await this.x.helper.asyncCreateStates({statePath:statePath, commonObject:newCommon}, false); } } statePathsUsed.push(statePathFull); } else { throw(`State path '${this.namespace}.${statePath}' is not valid.`); } } } // Delete states no longer needed const allUserStates = await this.getStatesOfAsync('userstates'); if (allUserStates == undefined) throw (`Could not get adapter instance state paths for 'userstates'.`); for (const lpState of allUserStates) { const existingStatePath = lpState._id; // like: 'smartcontrol.0.userstates.Coffeemaker' if ( statePathsUsed.indexOf(existingStatePath) == -1 ) { // State is no longer used. await this.delObjectAsync(existingStatePath); // Delete object and its state this.log.debug(`State '${existingStatePath}' deleted, since trigger does no longer exist.'`); } } /** * Validate Adapter Admin Configuration */ const configVerificationResult = await this._asyncVerifyConfig(this.config); if (configVerificationResult.passed) { this.config = configVerificationResult.obj; this.log.info('Adapter admin configuration successfully validated...'); } else { // Error(s) occurred. this.log.error(`[User Error (Anwenderfehler)] - ${configVerificationResult.issues.length} error(s) found in adapter configuration. You must fix these issue(s) to use this adapter.`); let counter = 0; for (const lpMsg of configVerificationResult.issues) { counter++; this.log.warn(`Issue #${counter}: ${lpMsg}`); } this.setState('info.connection', false, true); // change to yellow return; // Go out. } // set table 'tableTargetURLs' into config.tableTargetDevices this._setTargetsURLsToTargetDevicesTable(); /** * Create States: * A - smartcontrol.x.targetDevices.xxx states. * B - smartcontrol.x.targetURLs.xxx states. */ const statesToBeCreated = []; const statePaths = []; // A - smartcontrol.x.targetDevices.xxx states. for (const lpRow of this.config.tableTargetDevices) { if (!lpRow.active) continue; if ('isTargetURL' in lpRow) continue; // do not create states for TargetURLs, since we create separately anyway. if(/_enum-\d{1,3}$/.test(lpRow.name)) continue; // don't add enums const lpStatePath = `${this.namespace}.targetDevices.${lpRow.name.trim()}`; if (! this.x.helper.isStateIdValid(lpStatePath) ) throw(`Invalid state name portion provided in table 'Target Devices': '${lpRow.name}'`); const lpCommon = { name: lpRow.name, type: 'boolean', read: true, write: true, role: 'switch', def: false }; statesToBeCreated.push({statePath:lpStatePath, commonObject:lpCommon}); statePaths.push(lpStatePath); } // B - smartcontrol.x.targetURLs.xxx states. for (const lpRow of this.config.tableTargetURLs) { if (!lpRow.active) continue; const lpBaseStatePath = lpRow.stateId; // ${this.namespace} was already added in asyncVerifyConfig() const statesToProcess = { // state | common object to set 'call_on': { name: `Call '${lpRow.urlOn}'`, type: 'boolean', read: true, write: true, role: 'button', def: false }, 'response': { name: `Response from '${lpRow.urlOn}'`, type: 'string', read: true, write: false, role: 'state', def: '' }, }; if (lpRow.urlOff && !this.x.helper.isLikeEmpty(lpRow.urlOff)) { statesToProcess.call_off = { name: `Call '${lpRow.urlOff}'`, type: 'boolean', read: true, write: true, role: 'button', def: false }; //statesToProcess.response_off = { name: `Response from '${lpRow.urlOff}'`, type: 'string', read: true, write: false, role: 'state', def: '' }; } for (const lpKeyName in statesToProcess) { const obj = await this.getObjectAsync(lpBaseStatePath + '.' + lpKeyName); if (obj) { // Object exists, so we just update the name in common. // https://discordapp.com/channels/743167951875604501/743171252377616476/762360203878072391 await this.extendObjectAsync(lpBaseStatePath + '.' + lpKeyName, {common:{name:statesToProcess[lpKeyName].name}}); } else { statesToBeCreated.push({ statePath:lpBaseStatePath + '.' + lpKeyName, commonObject:statesToProcess[lpKeyName] }); } statePaths.push(lpBaseStatePath + '.' + lpKeyName,); } statePaths.push(lpBaseStatePath + '.response'); } // Create all states const createStatesResult = await this.x.helper.asyncCreateStates(statesToBeCreated); if (!createStatesResult) throw (`Certain error(s) occurred in asyncCreateStates().`); // Delete all states which are no longer used. const allTargetDevicesStates = await this.getStatesOfAsync('targetDevices'); if (allTargetDevicesStates == undefined) throw (`getStatesOfAsync(): Could not get adapter instance state paths for 'targetDevices'.`); const allTargetURLsStates = await this.getStatesOfAsync('targetURLs'); if (allTargetURLsStates == undefined) throw (`getStatesOfAsync(): Could not get adapter instance state paths for 'targetURLs'.`); for (const lpState of allTargetDevicesStates.concat(allTargetURLsStates)) { const statePath = lpState._id; // like: 'smartcontrol.0.targetDevices.Coffeemaker' if ( statePaths.indexOf(statePath) == -1 ) { // State is no longer used. await this.delObjectAsync(statePath); // Delete state. this.log.debug(`State '${statePath}' deleted, since option does no longer exist.'`); } } /** * Create smartcontrol.x.options.xxx states */ const statesFull = []; // with common objects const statePathsOnly = []; const tablesToProcess = ['tableTriggerMotion', 'tableTriggerDevices', 'tableTriggerTimes', 'tableTargetDevices', 'tableZones', 'tableConditions']; let errorCounter = 0; for (let i = 0; i < tablesToProcess.length; i++) { for (let k = 0; k < this.config[tablesToProcess[i]].length; k++) { const lpRow = this.config[tablesToProcess[i]][k]; const lpTableName = tablesToProcess[i]; // Table name from config, like 'tableTargetDevices'; const lpStateSubSection = lpTableName.substr(5); // 'tableTargetDevices' => 'TargetDevices' // Get the name of the table row and convert to a valid state portion. const lpRowNameStatePortion = lpRow.name; // like: 'Motion Bathroom' or 'At 04:05 every Sunday' if (this.x.helper.isLikeEmpty(lpRow.name.trim())) continue; // We do not add rows with blank name if (lpTableName == 'tableTargetDevices' && /_enum-\d{1,3}$/.test(lpRow.name)) continue; // Don't add enums for (const fieldName in lpRow){ const lpFieldEntry = lpRow[fieldName]; // like 'smartcontrol.0.Test.light.Bathroom' or true, etc. if (! ((['active', 'name'].indexOf(fieldName) !== -1) || (lpTableName == 'tableTriggerMotion' && (['duration', 'briThreshold'].indexOf(fieldName) !== -1)) )) continue; // Define the common object to create the state const lpCommonObject = {}; lpCommonObject.read = true; lpCommonObject.write = true; lpCommonObject.role = 'value'; // Apply different types. if (fieldName == 'active') { lpCommonObject.name = 'Please note: Changing this state restarts the adapter instance for being able to apply the change.'; lpCommonObject.type = 'boolean'; lpCommonObject.def = lpFieldEntry; } if (fieldName == 'name') { lpCommonObject.name = fieldName; lpCommonObject.write = false; // Don't allow to change the 'name' lpCommonObject.type = 'string'; lpCommonObject.def = (typeof lpFieldEntry != 'string') ? JSON.stringify(lpFieldEntry) : lpFieldEntry; } if (lpTableName == 'tableTriggerMotion' && (['duration', 'briThreshold'].indexOf(fieldName) !== -1)) { lpCommonObject.name = 'Please note: Changing this state restarts the adapter instance for being able to apply the change.'; lpCommonObject.type = 'number'; lpCommonObject.def = parseInt(lpFieldEntry); } const lpStatePath = `${this.namespace}.options.${lpStateSubSection}.${lpRowNameStatePortion}.${fieldName}`; // Like: 'options.TargetDevices.Bathroom Light' if (! this.x.helper.isStateIdValid(`${this.namespace}.${lpStatePath}`) ) { this.log.error(`[${tablesToProcess[i]}] We were not able to generate a valid state path. This is what was determined to be not valid: [${lpStatePath}].`); errorCounter++; continue; } statesFull.push({statePath:lpStatePath, commonObject:lpCommonObject }); statePathsOnly.push(lpStatePath); } } } if (errorCounter > 0) { throw(`${errorCounter} error(s) occurred while processing state generation of options.`); } else if (statesFull.length == 0) { throw(`No states to be created determined while processing state generation of options.`); } // Create states const res = await this.x.helper.asyncCreateStates(statesFull, false); if (!res) { throw(`Certain error(s) occurred in asyncCreateStates().`); } // Delete all states which are no longer used. const allAdapterStates = await this.getStatesOfAsync('options'); if (allAdapterStates != undefined) { for (const lpState of allAdapterStates) { const statePath = lpState._id; // like: 'smartcontrol.0.options.Zones.Hallway.name' if ( (statePathsOnly.indexOf(statePath) == -1) && (statePath.endsWith('active') || (statePath.endsWith('name') ) ) ) { // State is no longer used. await this.delObjectAsync(statePath); // Delete state. this.x.helper.logExtendedInfo(`State ${statePath} deleted, since option does no longer exist.`); } } } // Update option states. Required if admin options were changed and saved (which restarts adapter). for (const lpStatePath of statePathsOnly) { // {name:'Hallway', index:2, table:'tableZones', field:'active', row:{.....} } const optionObj = await this._asyncGetOptionForOptionStatePath(lpStatePath); // Set the state let val; if(typeof optionObj.row[optionObj.field] == 'object') { val = JSON.stringify(optionObj.row[optionObj.field]); } else { if (lpStatePath.endsWith('briThreshold')) { val = parseInt(optionObj.row[optionObj.field]); } else if (lpStatePath.endsWith('duration')) { val = parseInt(optionObj.row[optionObj.field]); } else { val = optionObj.row[optionObj.field]; } } await this.setStateAsync(lpStatePath, {val:val, ack:true}); } /** * Prepare Zones Status, setting per default to false (Zone is off) */ for (const lpZoneRow of this.config.tableZones) { if (lpZoneRow.active) { this.x.zonesIsOn[lpZoneRow.name] = false; } } /** * Init Trigger instances of Trigger class for each trigger */ // @ts-ignore No overload matches this call -> https://github.com/microsoft/TypeScript/issues/36769 const allTriggerRows = this.config.tableTriggerMotion.concat(this.config.tableTriggerDevices, this.config.tableTriggerTimes); for (const lpTriggerRow of allTriggerRows) { if (lpTriggerRow.active) { const triggerName = lpTriggerRow.name; this.x.triggers[triggerName] = new this.x.Trigger(this, triggerName); } } /** * STATE SUBSCRIPTIONS */ // STATE SUBSCRIPTION: to all smartcontrol.x.targetDevices states await this.subscribeStatesAsync('targetDevices.*'); // STATE SUBSCRIPTION: to all on/off states of tableTargetDevices for (const lpRow of this.config.tableTargetDevices) { if (lpRow.active) { await this.subscribeForeignStatesAsync(lpRow.onState); await this.subscribeForeignStatesAsync(lpRow.offState); } } // STATE SUBSCRIPTION: to all smartcontrol.x.targetURLs states for (const lpRow of this.config.tableTargetURLs) { if (lpRow.active) { await this.subscribeStatesAsync(lpRow.stateId + '.call_on'); if (lpRow.urlOff && !this.x.helper.isLikeEmpty(lpRow.urlOff)) { await this.subscribeStatesAsync(lpRow.stateId + '.call_off'); } } } // STATE SUBSCRIPTION: to all 'smartcontrol.0.options.TriggerMotion.xxx.<duration|briThreshold>' await this.subscribeStatesAsync('smartcontrol.0.options.TriggerMotion.*.duration'); await this.subscribeStatesAsync('smartcontrol.0.options.TriggerMotion.*.briThreshold'); // STATE SUBSCRIPTION: to all 'smartcontrol.x.options.x.x.active' await this.subscribeStatesAsync('options.*.active'); // STATE SUBSCRIPTION: to Triggers: Motion and Devices // @ts-ignore -> https://github.com/microsoft/TypeScript/issues/36769 for (const lpRow of this.config.tableTriggerMotion.concat(this.config.tableTriggerDevices)) { if (lpRow.active) { const statePath = lpRow.stateId; // like '0_userdata.0.motion-sensor.Bathroom.motion' await this.subscribeForeignStatesAsync(statePath); // Info: we already validated in asyncVerifyConfig() if state exists } } /** * Schedule all trigger times */ const numTriggers = await this._asyncOnReady_asyncScheduleTriggerTimes(); /** * Re-schedule all trigger times every midnight. Also, refresh astro states. * This is required since we are supporting astro times (suncalc) */ this.x.schedules.midnight = this.x.mSchedule.scheduleJob('0 0 * * *', () => { this._asyncOnReady_asyncScheduleTriggerTimes(); if (this.x.systemConfig.latitude && this.x.systemConfig.longitude) this._asyncOnReady_asyncRefreshAstroStates(); this.x.helper.logExtendedInfo(`Re-scheduling time triggers for updating astro times and updating 'info.astroTimes.' states.`); }); this.log.info(`Subscribing to all target devices and trigger states. ${numTriggers} trigger schedules activated...`); this.setState('info.connection', true, true); // change to green } catch (error) { this.x.helper.dumpError('[_asyncOnReady()]', error); this.setState('info.connection', false, true); // change to yellow return; } } /** * Get messages from Adapter Configuration UI * Using this method requires "common.message" property to be set to true in io-package.json * @param {ioBroker.Message} obj */ async _asyncOnMessage(obj) { try { if (typeof obj !== 'object') throw (`Verify input object: object is not defined.`); if (!obj.message) throw (`Verify input object: object.message is not defined.`); if (!obj.callback) throw (`Verify input object: object.callback is not defined.`); /********************************** * Verify the configuration from admin/index_m.html, once user hits the save button. *********************************/ if (obj.command == 'verifyConfig') { this.log.debug(`[_asyncOnMessage] command 'Verify Adapter Configuration' received from admin/index_m.html. Verifying the configuration and sending the result back to index_m.html...`); // Verify and send the result back to admin/index_m.html const configVerificationResult = await this._asyncVerifyConfig(obj.message); this.sendTo(obj.from, obj.command, configVerificationResult, obj.callback); // Some log if (configVerificationResult.passed) { this.log.debug(`[_asyncOnMessage] Configuration successfully verified, no issues found.`); } else { this.log.debug(`[_asyncOnMessage] Configuration verification failed, ${configVerificationResult.issues.length} issue(s) found.`); } } else { throw (`[_asyncOnMessage] sendTo() received object, but command '${obj.command}' is not defined in this method.`); } } catch (error) { this.x.helper.dumpError('[_asyncOnMessage()]', error); } } /** * Schedule all trigger times of Trigger Times table * * @return {Promise<number>} number of schedules activated. * @fires trigger.asyncSetTargetDevices() */ async _asyncOnReady_asyncScheduleTriggerTimes() { try { let counter = 0; for (const lpRow of this.config.tableTriggerTimes) { if (lpRow.active) { // Convert non-crons to cron let cron = ''; if (this.x.helper.isCronScheduleValid(lpRow.time.trim())) { cron = lpRow.time.trim(); } else { const ts = this.x.helper.getTimeInfoFromAstroString(lpRow.time, true).timestamp; if (ts == 0) { this.log.warn(`No valid time in Trigger Times table, row ${lpRow.name}: ${lpRow.time}`); continue; } const date = new Date(ts); cron = `${date.getMinutes()} ${date.getHours()} * * *`; } // Cancel schedule first. See issue https://github.com/Mic-M/ioBroker.smartcontrol/issues/43 if(this.x.schedules[lpRow.name] !== undefined && this.x.schedules[lpRow.name] !== null) { this.x.schedules[lpRow.name].cancel(); this.x.schedules[lpRow.name] = null; // just in case. } this.x.schedules[lpRow.name] = this.x.mSchedule.scheduleJob(cron, async () => { const triggerName = lpRow.name; const trigger = this.x.triggers[triggerName]; // Trigger class instance this.log.debug(`--- Trigger [${triggerName}] was triggered per schedule (time: ${trigger.triggerTime}) ---`); // First check if additional conditions are met or "never if" let doContinue = await trigger.asyncAreScheduleConditionsMet('Trigger: Additional Conditions', trigger.triggerTmAdditionCond, trigger.triggerTmAddCondAll); if (doContinue) doContinue = await trigger.asyncAreScheduleConditionsMet('Trigger: "Never if" Conditions', trigger.triggerTmNever, trigger.triggerTmNeverAll, true); if(!doContinue) { this.x.helper.logExtendedInfo(`Trigger [${triggerName}] (time: ${trigger.triggerTime}) triggered, but condition(s) not met.`); return; } trigger.asyncSetTargetDevices(); }); counter++; } } return counter; } catch (error) { this.x.helper.dumpError('[_asyncOnReady_asyncScheduleTriggerTimes]', error); return 0; } } /** * Refresh the astro states under smartcontrol.x.info.astroTimes */ async _asyncOnReady_asyncRefreshAstroStates() { try { for (let i = 0; i < this.x.constants.astroTimes.length; i++) { const lpAstro = this.x.constants.astroTimes[i]; const ts = this.x.helper.getAstroNameTs(lpAstro); const astroTimeStr = this.x.helper.timestampToTimeString(ts, true); await this.setStateAsync(`info.astroTimes.${this.x.helper.zeroPad(i+1, 2)}_${lpAstro}`, {val: astroTimeStr, ack: true }); await this.setStateAsync(`info.astroTimes.timeStamps.${lpAstro}`, {val: ts, ack: true }); } } catch (error) { this.x.helper.dumpError('[_asyncOnReady_asyncRefreshAstroStates()]', error); return false; } } /** * Initialized by Class constructor and called once a subscribed state changes * * @param {string} statePath State Path * @param {ioBroker.State | null | undefined} stateObject State object */ async _asyncOnStateChange(statePath, stateObject) { try { let stateChangeCounter = 0; if (!stateObject) { // this.log.debug(`Subscribed state '${statePath}' was deleted.`); return; } // this.log.debug(`Subscribed state '${statePath}' changed, new val: [${stateObject.val}] (ack: ${stateObject.ack}).`); // Check acknowledge (ack) const ackPassingResult = await this.x.helper.isAckPassing(statePath, stateObject); if ( ! ackPassingResult.passing ) { if (!statePath.startsWith(this.namespace)) // no log needed for smartcontrol.x states this.log.debug(`State Change: IGNORED – state '${statePath}' change: ack '${stateObject.ack}' - ${ackPassingResult.msg}`); return; } else { this.log.debug(`State Change: ACCEPTED – state '${statePath}' change: ack '${stateObject.ack}' - ${ackPassingResult.msg}`); } /** * State Change: smartcontrol.0.options.XXX.XXX.active */ if (statePath.startsWith(`${this.namespace}.options.`) && statePath.endsWith('.active')) { stateChangeCounter++; this.log.debug(`smartcontrol options.XXX.XXX.active - Subscribed state '${statePath}' changed.`); // {name:'Hallway', index:2, table:'tableZones', field:'active', row:{.....} } const optionObj = await this._asyncGetOptionForOptionStatePath(statePath); // Check if new value != old value if (optionObj.row[optionObj.field] == stateObject.val) { this.log.info(`Smart Control Adapter State '${statePath}' changed to '${stateObject.val}', but is equal to old state val, so no action at this point.`); } else { this.x.helper.logExtendedInfo(`Smart Control Adapter State '${statePath}' changed to '${stateObject.val}'.`); await this.setStateAsync(statePath, {val:stateObject.val, ack: true}); // Acknowledge State Change // Set config change into adapter configuration. This will also restart the adapter instance by intention. // Restart is required since an activation or deactivation of a table row has multiple effects. this.log.info(`State change of '${statePath}' to '${stateObject.val}' now executes an adapter instance restart to put the change into effect.`); const resultObject = await this.getForeignObjectAsync(`system.adapter.${this.namespace}`); if (resultObject) { resultObject.native[optionObj.table][optionObj.index][optionObj.field] = stateObject.val; await this.setForeignObjectAsync(`system.adapter.${this.namespace}`, resultObject); } else { throw('getForeignObjectAsync(): No object provided from function.'); } } } /** * State Change: smartcontrol.0.options.TriggerMotion.xxx.duration and .briThreshold */ if (statePath.startsWith(`${this.namespace}.options.TriggerMotion.`) && (statePath.endsWith('.duration') || (statePath.endsWith('.briThreshold') ))) { stateChangeCounter++; this.log.debug(`smartcontrol options.TriggerMotion<duration|briThreshold> - Subscribed state '${statePath}' changed.`); // {name:'Motion.Bathroom', index:2, table:'tableTriggerMotion', field:'active', row:{.....} } const optionObj = await this._asyncGetOptionForOptionStatePath(statePath); // Check if new value != old value if (optionObj.row[optionObj.field] == stateObject.val) { this.x.helper.logExtendedInfo(`Smart Control Adapter State '${statePath}' changed to '${stateObject.val}', but is equal to old state val, so no action at this point.`); } else { this.x.helper.logExtendedInfo(`Smart Control Adapter State '${statePath}' changed to '${stateObject.val}'.`); await this.setStateAsync(statePath, {val:stateObject.val, ack: true}); // Acknowledge State Change // Set config change into adapter configuration. This will also restart the adapter instance by intention. // Restart is required since an activation or deactivation of a table row has multiple effects. this.log.info(`State change of '${statePath}' to '${stateObject.val}' now executes an adapter instance restart to put the change into effect.`); const resultObject = await this.getForeignObjectAsync(`system.adapter.${this.namespace}`); if (resultObject) { const stateVal = (!stateObject.val) ? '' : stateObject.val.toString(); // the number is a string in adapter config resultObject.native[optionObj.table][optionObj.index][optionObj.field] = stateVal; await this.setForeignObjectAsync(`system.adapter.${this.namespace}`, resultObject); } else { throw('getForeignObjectAsync(): No object provided from function.'); } } } /** * State Change: smartcontrol.x.targetDevices.xxx */ if (statePath.startsWith(`${this.namespace}.targetDevices.`)) { stateChangeCounter++; this.log.debug(`smartcontrol targetDevices - Subscribed state '${statePath}' changed.`); let targetDevicesRow = {}; for (const lpRow of this.config.tableTargetDevices) { if (!lpRow.active) continue; if (`${this.namespace}.targetDevices.${lpRow.name}` == statePath) { targetDevicesRow = lpRow; break; } } if(this.x.helper.isLikeEmpty(targetDevicesRow)) throw (`Table 'Target Devices': No row found for state path ${statePath}`); // Set target states. // Note: Verification of the state value and conversion as needed was performed already by asyncVerifyConfig() const w = (stateObject.val) ? 'on' : 'off'; await this.setForeignStateAsync(targetDevicesRow[w+'State'], {val: targetDevicesRow[w+'Value'], ack: false }); // confirm by ack:true await this.setStateAsync(statePath, {val: stateObject.val, ack: true }); } /** * State Change: smartcontrol.0.targetURLs.xxx.call_on / smartcontrol.0.targetURLs.xxx.call_off */ if (statePath.startsWith(`${this.namespace}.targetURLs.`) && (statePath.endsWith('.call_on') || statePath.endsWith('.call_off')) && stateObject.val === true) { stateChangeCounter++; this.log.debug(`smartcontrol targetURLs - Subscribed state '${statePath}' changed.`); // Prefix 'smartcontrol.0.targetURLs.' was already added in asyncVerifyConfig() to name and // available as "stateId" is table rows let stateMainObjectId; let what = ''; // on or off if (statePath.endsWith('.call_on')) { what = 'On'; stateMainObjectId = statePath.substring(0, statePath.length-8); // remove '.call_on' } else { what = 'Off'; stateMainObjectId = statePath.substring(0, statePath.length-9); // remove '.call_off' } const name = this.getOptionTableValue('tableTargetURLs', 'stateId', stateMainObjectId, 'name'); const url = this.getOptionTableValue('tableTargetURLs', 'stateId', stateMainObjectId, 'url' + what); if (name && url) { try { this.log.debug(`Trying to call target URL (name: '${name}', URL: '${url}')`); // @ts-ignore This expression is not callable. const response = await this.x.axios.get(url); this.log.debug(`Calling target URL was successful (name: '${name}', URL: '${url}')`); // Set response to state smartcontrol.x.targetURLs.xxxxxxx.response if (this.x.helper.isLikeEmpty(response.body)) { this.setState(stateMainObjectId + '.response', {val: '(no response text provided)', ack: true }); // no async needed here this.log.info(`No 'response.body' as URL call response for name: '${name}', URL: '${url}'`); } else { const responseVal = (typeof response.body === 'string') ? response.body : JSON.stringify(response.body); this.setState(stateMainObjectId + '.response', {val: responseVal, ack: true }); // no async needed here this.log.debug(`URL call response: [${responseVal}]`); } } catch (error) { this.setState(stateMainObjectId + '.response', {val: 'Error: ' + error.response.body, ack: true }); // no async needed here this.x.helper.dumpError(`Error calling target URL - name: '${name}', URL: '${url}'`); this.x.helper.dumpError('Message: ', error.response.body); } } else { this.log.error(`State Change '${statePath}': Could not get name and/or URL from Table 'Targets: URLs'`); } } /** * State Change: tableTargetDevices: on/off states */ if ( (this.getOptionTableValue('tableTargetDevices', 'onState', statePath, 'name') != undefined) || (this.getOptionTableValue('tableTargetDevices', 'offState', statePath, 'name') != undefined) ) { stateChangeCounter++; this.log.debug(`State Change: tableTargetDevices: on/off states - Subscribed state '${statePath}' changed.`); for (const lpTargetDeviceRow of this.config.tableTargetDevices) { if (!lpTargetDeviceRow.active) continue; if (lpTargetDeviceRow.onState != statePath || lpTargetDeviceRow.offState != statePath) continue; if (lpTargetDeviceRow.onState == statePath && lpTargetDeviceRow.onValue == stateObject.val) { // Set "linked" state. if (/_enum-\d{1,3}$/.test(lpTargetDeviceRow.name)) { this.log.debug('enum identified. No state set.'); } else { this.log.debug('enum not identified'); await this.setStateAsync(`targetDevices.${lpTargetDeviceRow.name}`, {val: true, ack: true }); } this.log.debug(`State '${statePath}' changed to '${stateObject.val}' -> '${this.namespace}.targetDevices.${lpTargetDeviceRow.name}' set to true.`); } else if (lpTargetDeviceRow.offState == statePath && lpTargetDeviceRow.offValue == stateObject.val) { /** * First: Set "is Zone on" status to false if - with this new state change - all targets of the zone will be off. */ for (const lpZoneRow of this.config.tableZones) { if (!lpZoneRow.active) continue; if (!lpZoneRow.targets.includes(lpTargetDeviceRow.name)) continue; let counter = 0; for (const lpZoneTargetName of lpZoneRow.targets) { if (!lpZoneRow.active) continue; if (lpTargetDeviceRow.name == lpZoneTargetName) { // No need to re-check current state value counter++; continue; } else { const configOffStateId = this.getOptionTableValue('tableTargetDevices', 'name', lpZoneTargetName, 'offState'); const configOffStateVal = this.getOptionTableValue('tableTargetDevices', 'name', lpZoneTargetName, 'offValue'); const actualStateVal = await this.x.helper.asyncGetForeignStateValue(configOffStateId); if (actualStateVal === configOffStateVal) counter++; } } if (counter === lpZoneRow.targets.length) { // Set "is Zone on" status to false since all target devices do have the 'off' state now. this.x.zonesIsOn[lpZoneRow.name] = false; // Cancel timers // NOTE: We cannot cancel trigger timer 'motionTimer' since it may also trigger other zones. // TODO: We should address motion timers as well. const timerObjIds = ['timersZoneOff', 'timersMimicMotionTrigger']; let timerCounter = 0; for (const lpTimerObjId of timerObjIds) { const lpTimeLeft = this.x.helper.getTimeoutTimeLeft(this.x[lpTimerObjId][lpZoneRow.name]); if (lpTimeLeft > -1) { clearTimeout(this.x[lpTimerObjId][lpZoneRow.name]); this.x[lpTimerObjId][lpZoneRow.name] = null; timerCounter++; } } if (timerCounter > 0) { this.log.debug(`All targets of zone switched off. Therefore, ${timerCounter} running timers canceled..`); } else { this.log.debug(`All targets of zo