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
JavaScript
'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