lib/agent/actions/triggers/index.js
'use strict';
var schedule = require('node-schedule');
var EventEmitter = require('events').EventEmitter;
var logger = require('./../../common').logger.prefix('triggers');
var api = require('./../../control-panel/api');
var hooks = require('./../../hooks');
var commands = require('./../../commands');
var storage = require('./../../utils/storage');
var lp = require('./../../control-panel/long-polling');
var emitter;
var running_triggers = [];
var event_triggers = {};
var current_triggers = [];
let timeoutStartTrigger;
let websocket = require('../../control-panel/websockets');
const events_list = [
'connected',
'disconnected',
'geofencing_in',
'geofencing_out',
'new_location',
'mac_address_changed',
'ssid_changed',
'private_ip_changed',
'low_battery',
'started_charging',
'stopped_charging',
'hardware_changed',
'device_unseen',
];
const error_status_list = {
0: 'Unknown error',
1: 'Success!',
2: 'Invalid trigger format',
3: 'Cant set trigger into the past!',
4: "The execution range dates doesn't make sense.",
5: 'Unavailable event for Node Client.',
6: 'Persisting action!',
};
function fetch_triggers(cb) {
api.devices.get.triggers(cb);
}
function done(id, err, cb) {
if (emitter) {
setTimeout(() => {
emitter.emit('end', id, err);
}, 1000);
}
if (cb && typeof cb == 'function') return cb() && cb(err);
else return null;
}
function send_response(status, id_list) {
logger.debug(
'sending ' +
status +
' w/ code ' +
JSON.stringify(id_list) +
" to prey' control panel"
);
var data = {
status: status,
command: 'start',
target: 'triggers',
reason: JSON.stringify(id_list),
};
api.push.methods['response'](data);
}
function cancel_hooks() {
if (Object.keys(event_triggers).length == 0) return;
Object.keys(event_triggers).forEach((event) => {
hooks.remove(event);
});
}
function check_repeat(date, repeat) {
var limit;
try {
let hour_from = repeat.hour_from,
hour_until = repeat.hour_until,
date_from = date.setHours(
hour_from.slice(0, 2),
hour_from.slice(2, 4),
hour_from.slice(4, 6)
),
date_until = date.setHours(
hour_until.slice(0, 2),
hour_until.slice(2, 4),
hour_until.slice(4, 6)
);
limit = { from: date_from, until: date_until };
} catch (e) {
limit = {};
}
return limit;
}
function check_range_format(from, until) {
if (from > until) return false;
return true;
}
function check_repeat_format(days, hour_from, hour_until, until) {
if (days.some(isNaN)) return false;
else if (hour_from.length != 6 || hour_until.length != 6) return false;
else if (until && until.length != 8) return false;
else return true;
}
function check_rules(rule) {
if (rule.second && (rule.second < 0 || rule.second > 60)) return false;
if (rule.minute && (rule.minute < 0 || rule.minute > 60)) return false;
if (rule.hour && (rule.hour < 0 || rule.hour > 24)) return false;
if (rule.dayOfWeek.some((elem) => elem > 6 || elem < 0)) return false;
return true;
}
function run_trigger_actions(trigger) {
// Update last_exec
storage.do(
'update',
{
type: 'triggers',
id: trigger.id,
columns: 'last_exec',
values: new Date().getTime(),
},
() => {
send_response('stopped', [trigger.id]);
storage.do('del', { type: 'triggers', id: trigger.id });
return;
}
);
trigger.automation_actions.forEach((action) => {
run_action(trigger, action);
});
}
function run_action(trigger, action) {
var timeout = 0;
if (action.delay && action.delay > 0) timeout = action.delay;
setTimeout(() => {
if (action.action.options) action.action.options.trigger_id = trigger.id;
commands.perform(action.action, trigger.persist);
}, timeout);
}
exports.cancel_all = () => {
if (running_triggers.length > 0) {
running_triggers.forEach((trigger) => {
trigger.cancel();
});
running_triggers = [];
}
cancel_hooks();
};
function to_unix(date) {
var new_date;
try {
if (/^\d+$/.test(date)) {
var year = parseInt(date.slice(0, 4)),
month = parseInt(date.slice(4, 6)) - 1, // January its 0
day = parseInt(date.slice(6, 8)),
hour = parseInt(date.slice(8, 10)),
minute = parseInt(date.slice(10, 12)),
second = parseInt(date.slice(12, 14)),
timezone_offset = new Date().getTimezoneOffset() * 60 * 1000; // miliseconds
new_date =
new Date(Date.UTC(year, month, day, hour, minute, second)).getTime() +
timezone_offset;
} else {
date = date.split('Z');
if (date.length > 1) {
if (date[1].length > 0)
date = date[1].charAt(0) == '-' ? date.join('') : date.join('+');
else date = date[0];
}
new_date = new Date(date).getTime();
}
} catch (e) {
new_date = NaN;
}
return parseInt(new_date);
}
function set_up_hooks() {
if (Object.keys(event_triggers).length == 0) return;
Object.keys(event_triggers).forEach((event) => {
hooks.on(event.split('-')[0], (info) => {
if (info && info.id && event.split('-')[1] != info.id) return;
event_triggers[event].forEach((action) => {
var timeout = 0;
if (action.delay && action.delay > 0) timeout = action.delay;
var date = new Date();
if (action.repeat) {
if (action.repeat.days_of_week.indexOf(date.getDay()) == -1) return;
var aux_date = new Date(date.valueOf());
var dates = check_repeat(aux_date, action.repeat);
if (
Object.keys(dates).length == 0 ||
date.getTime() < dates.from ||
date.getTime() > dates.until
)
return;
}
if (action.after) {
var last_connection = websocket.lastConnection();
if (!last_connection) {
logger.debug('No last connection to server registered');
return;
}
if (last_connection + action.after > Math.round(Date.now() / 1000))
return;
// Don't do if it was already executed
var trigger_index = current_triggers.findIndex(
(obj) => obj.id === action.trigger_id
);
if (current_triggers[trigger_index].last_exec) return;
var exec_time = new Date().getTime();
current_triggers[trigger_index].last_exec = exec_time;
var current = current_triggers[trigger_index];
storage.do(
'update',
{
type: 'triggers',
id: current.id,
columns: 'synced_at',
values: exec_time,
},
(err) => {
if (err) logger.debug('Unable to update the execution time');
}
);
}
if (action.range) {
var date_from = action.range.from,
date_until = action.range.until;
if (
isNaN(date_from) ||
isNaN(date_until) ||
date.getTime() < date_from ||
date.getTime() > date_until
)
return;
}
setTimeout(() => {
if (action.action.options){
action.action.options.trigger_id = action.trigger_id;
try{
websocket.notify_action(action.action, action.action.options.trigger_id, action.action.target, null, null, null);
}catch(ex){
logger.debug('action from trigger not notified');
}
}
logger.debug('action to perform 1')
logger.debug(JSON.stringify(action));
commands.perform(action.action);
}, timeout);
});
});
});
}
exports.activate_event = (trigger) => {
try {
var event_index = trigger.automation_events.findIndex(
(obj) => events_list.indexOf(obj.type) > -1
);
} catch (e) {
return 2;
}
if (event_index == -1) return 5;
var event = trigger.automation_events[event_index].type;
var info = trigger.automation_events[event_index].info;
if (!event_triggers[event]) {
if (info && info.id) event = [event, info.id].join('-');
event_triggers[event] = [];
}
trigger.automation_actions.forEach((action) => {
// If there's an element with type 'repeat_range_time' we keep the index
var index_repeat = trigger.automation_events.findIndex(
(obj) => obj.type === 'repeat_range_time'
),
index_range = trigger.automation_events.findIndex(
(obj) => obj.type === 'range_time'
),
index_after = trigger.automation_events.findIndex(
(obj) => obj.type === 'after_time'
);
if (index_after > -1) {
var seconds = trigger.automation_events[index_after].info.seconds;
action.after = seconds;
}
if (index_repeat > -1) {
var days = JSON.parse(
trigger.automation_events[index_repeat].info.days_of_week
),
hour_from = trigger.automation_events[index_repeat].info.hour_from,
hour_until =
trigger.automation_events[index_repeat].info.hour_until.slice(0, -2) +
'59', // Include that last minute
until = trigger.automation_events[index_repeat].info.until;
if (until) {
var year = parseInt(until.slice(0, 4)),
month = parseInt(until.slice(4, 6)) - 1, // January its 0
day = parseInt(until.slice(6, 8)) + 1; // One more day, until next day at 00:00
var end_date = new Date(year, month, day),
current_date = new Date();
if (current_date > end_date) return 3;
}
if (!check_repeat_format(days, hour_from, hour_until, until)) return 4;
action.repeat = {
days_of_week: days,
hour_from: hour_from,
hour_until: hour_until,
until: until,
};
}
if (index_range > -1) {
var date_from = to_unix(
trigger.automation_events[index_range].info.from + '000000'
),
date_until = to_unix(
trigger.automation_events[index_range].info.until + '235959'
);
if (current_date > date_until) return 2;
if (!check_range_format(date_from, date_until)) return 4;
action.range = { from: date_from, until: date_until };
}
action.trigger_id = trigger.id;
event_triggers[event].push(action);
});
return 1;
};
/**
* activates the trigger in prey's control panel
* @param {object} trigger - the object with trigger info
*/
exports.activate = (trigger) => {
try {
var index = trigger.automation_events.findIndex(
(obj) => obj.type === 'exact_time' || obj.type === 'repeat_time'
),
info = trigger.automation_events[index].info,
opts;
} catch (e) {
return 2;
}
// EXACT TIME!!
if (info.date) {
opts = to_unix(info.date);
if (isNaN(opts)) return 2;
let current_date = new Date().getTime();
if (current_date > opts) {
if ((trigger.persist == true || trigger.persist == 1) && !trigger.last_exec) {
run_trigger_actions(trigger);
return 6;
}else {
return 3;
}
}
// REPEAT TIME
} else if (info.days_of_week && info.hour) {
// At least the days and hour
try {
let rule = new schedule.RecurrenceRule();
rule.second = parseInt(info.second) || 0;
rule.minute = parseInt(info.minute) || null;
rule.hour = parseInt(info.hour) || null;
rule.dayOfWeek = JSON.parse(info.days_of_week) || null;
if (!check_rules(rule)) return 2;
opts = { rule: rule };
if (info.until) {
let until_date = info.until,
year = parseInt(until_date.slice(0, 4)),
month = parseInt(until_date.slice(4, 6)) - 1, // January its 0
day = parseInt(until_date.slice(6, 8)) + 1; // One more day, until next day at 00:00
let end_date = new Date(year, month, day);
let current_date = new Date();
if (current_date > end_date) return 3;
opts.end = end_date;
}
} catch (e) {
return 2;
}
} else return 2;
let index_repeat;
let index_range;
try {
index_repeat = trigger.automation_events.findIndex((obj) => obj.type === 'repeat_range_time');
index_range = trigger.automation_events.findIndex((obj) => obj.type === 'range_time');
} catch (e) {
return 2;
}
let repeat;
if (index_repeat > -1) {
let repeat_params = trigger.automation_events[index_repeat].info;
let days = JSON.parse(repeat_params.days_of_week);
let hour_from = repeat_params.hour_from;
let hour_until = repeat_params.hour_until;
let until = repeat_params.info.until;
if (!check_repeat_format(days, hour_from, hour_until, until)) return 4;
repeat = {
days_of_week: days,
hour_from: hour_from,
hour_until: hour_until,
until: until,
};
}
let range;
if (index_range > -1) {
let range_params = trigger.automation_events[index_range].info,
date_from = to_unix(range_params.from),
date_until = to_unix(range_params.until);
if (!check_range_format(date_from, date_until)) return 4;
range = { from: date_from, until: date_until };
}
var da_trigger = schedule.scheduleJob(opts, () => {
var date = new Date();
if (repeat) {
if (repeat.days_of_week.indexOf(date.getDay()) == -1) return;
let aux_date = new Date(date.valueOf());
let dates = check_repeat(aux_date, repeat);
if (
Object.keys(dates).length == 0 ||
date.getTime() < dates.from ||
date.getTime() > dates.until
)
return;
}
if (range) {
let date_from = range.from;
let date_until = range.until;
if (isNaN(date_from) || isNaN(date_until) || date.getTime() < date_from || date.getTime() > date_until)
return;
}
run_trigger_actions(trigger);
});
if (da_trigger) {
running_triggers.push(da_trigger);
return 1;
}
return 0;
};
exports.sync = (id, err, triggers, stored, cb) => {
var watching = [];
if (err) {
logger.error('error starting async: ' + err);
triggers = stored;
}
logger.debug('retrieved triggers: ' + JSON.stringify(triggers));
logger.debug('stored triggers: ' + JSON.stringify(stored));
if (triggers.length == 0) {
delete_all_triggers(id, cb); // delete all triggers in local db
}
let triggersToWatch = [...triggers];
let triggersStoredToWatch = [...stored];
current_triggers = assign_active_triggers(stored, triggers); // only active triggers
stored = delete_obsolete_triggers(stored, triggers); // will remove stored triggers that were deleted in control panel
logger.debug('current triggers: ' + JSON.stringify(current_triggers));
logger.debug('stored triggers: ' + JSON.stringify(stored));
logger.debug('triggersToWatch: ' + JSON.stringify(triggersToWatch));
exports.cancel_all();
event_triggers = {};
// iterate over only active and filtered triggers
triggersToWatch.forEach((trigger, index) => {
if (typeof trigger.automations_events == 'string') {
try {
trigger.automations_events = JSON.parse(trigger.automations_events);
trigger.automations_actions = JSON.parse(trigger.automations_action);
} catch (e) {
logger.warn('Error parsing trigger options: ' + e.message);
}
}
if (trigger.options && trigger.options.persist == true) {
trigger.persist = 1;
}
if (!trigger.synced_at) {
trigger.synced_at = new Date().getTime();
trigger.last_exec = null;
}
let state;
try {
if (
trigger.automation_events.some(
(obj) => obj.type === 'exact_time' || obj.type === 'repeat_time'
)
) {
state = exports.activate(trigger);
} else state = exports.activate_event(trigger);
} catch (e) {
state = 0;
}
var finish = () => {
if (index == triggers.length - 1 && watching.length > 0) {
send_response('started', watching);
}
};
var stored_index = stored.findIndex((x) => x.id === trigger.id);
if (stored_index == -1 || state != 1) {
watching.push({ id: trigger.id, state: state });
}
if (state != 1 && state != 6) {
logger.warn(
'Unable to set up trigger "' +
trigger.name +
'": ' +
error_status_list[state]
);
if (
trigger.persist == false ||
trigger.persist == null ||
trigger.persist == 0
) {
send_response('stopped', [trigger.id]);
}
if (index == triggers.length - 1) {
done(id, null, cb);
}
finish();
} else if (triggersStoredToWatch.filter((x)=> x.id == trigger.id).length == 0) {
logger.debug('saving stored trigger ID: ' + trigger.id);
if (state == 6) {
logger.warn('Persisting action for ' + trigger.name);
}
if (!trigger.persist) {
trigger.persist = 0;
}
var data = {
id: trigger.id,
name: trigger.name,
persist: trigger.persist, // persist as initial state? // valid values are 0 or 1
synced_at: trigger.synced_at,
last_exec: trigger.last_exec,
automation_events: trigger.automation_events,
automation_actions: trigger.automation_actions,
};
if (
!trigger.persist ||
trigger.persist == false ||
trigger.persist == 0
) {
data.persist = 0;
}
if (
trigger.persist &&
(trigger.persist == true || trigger.persist == 1)
) {
data.persist = 1;
}
storage.do(
'set',
{ type: 'triggers', id: trigger.id, data: data },
(err) => {
if (err) {
logger.error('Error storing triggers: ' + err);
}
if (index == triggers.length - 1) {
done(id, null, cb);
}
finish();
}
);
}
});
set_up_hooks();
};
function assign_active_triggers(stored, triggers) {
logger.debug('assigning active triggers');
logger.debug(
'iterates over: ' +
triggers.map(function (item) {
return item['id'];
})
);
logger.debug(
'compare against stored: ' +
stored.map(function (item) {
return item['id'];
})
);
const intersected_triggers = triggers.filter((new_trigger) => {
return !stored.some((old_trigger) => {
return old_trigger.id.toString() === new_trigger.id.toString();
});
});
logger.debug(
'result: ' +
intersected_triggers.map(function (item) {
return item['id'];
})
);
return intersected_triggers;
}
function delete_all_triggers(id, cb) {
logger.debug('deleting all triggers');
exports.clear_triggers((err) => {
if (err) {
return done(id, err, cb);
}
});
}
function delete_obsolete_triggers(stored, triggers) {
logger.debug('deleting outdated triggers');
logger.debug(
'iterates over: ' +
triggers.map(function (item) {
return item['id'];
})
);
logger.debug(
'compare against stored: ' +
stored.map(function (item) {
return item['id'];
})
);
const intersected_triggers = stored.filter((old_trigger) => {
return !triggers.some((new_trigger) => {
return old_trigger.id.toString() === new_trigger.id.toString();
});
});
logger.debug(
'result: ' +
intersected_triggers.map(function (item) {
return item['id'];
})
);
intersected_triggers.forEach((o) =>
storage.do('del', { type: 'triggers', id: o.id })
);
return intersected_triggers;
}
function handle_triggers_succesfully(success, id, cb, triggers = null) {
storage.do('all', { type: 'triggers' }, (error, stored_triggers) => {
if (error || !stored_triggers) {
return done(id, error, cb);
}
logger.debug('triggers fetched from API successfully: ' + success);
if (success) {
exports.sync(id, null, triggers, stored_triggers, () => {
cb && typeof(cb) == 'function' && cb(null, emitter);
});
} else {
exports.clear_triggers((err) => {
exports.sync(id, err, [], stored_triggers, () => {
cb && typeof(cb) == 'function' && cb(null, emitter);
});
});
}
});
}
function refresh_triggers(id, cb) {
logger.info('retrieving triggers from API');
emitter = emitter || new EventEmitter();
if (timeoutStartTrigger) clearTimeout(timeoutStartTrigger);
timeoutStartTrigger = setTimeout(() => {
fetch_triggers((err, res) => {
if (err) {
handle_triggers_succesfully(false, id, cb);
} else {
var fetched_triggers = res.body;
if (!(fetched_triggers instanceof Array)) {
return done(id, new Error('Triggers list is not an array'), cb);
}
handle_triggers_succesfully(true, id, cb, fetched_triggers);
}
});
}, 8000);
}
exports.clear_triggers = (cb) => {
logger.debug('cleaning triggers from local db');
storage.do('clear', { type: 'triggers' }, (err) => {
if (err) logger.error(err.message);
return cb() && cb(err);
});
};
exports.start = exports.stop = refresh_triggers;
exports.logger = logger;