homebridge-away-mode
Version:
Trigger sensors on and off randomly to simulate occupancy
612 lines (503 loc) • 22.5 kB
JavaScript
////////////////////////////////////////////////////////////////////////////////
//
// AwayMode - Homebridge plugin that provides triggers to turn on and turn off
// lights to simulate occupancy. We provide the triggers, you provide
// the lights.
//
// A simulated switch is created that controls whether "away mode" is active.
// When the switch is on, away mode is active. When the switch is off, away mode
// is inactive. A set of simulated sensors detect "activity". When activity
// (motion) is detected, turn the light on. When activity (motion) is not
// detected, turn the light off. The behavior of each sensor is random: a sensor
// is off for a period of time, turns on for a period of time, then repeats.
// When the switch is turned on, the sensors are activiated to start their
// off/on behavior. When the switch is turned off, the sensors are deactived
// and turned off.
//
// Config - (defaults in parenthesis)
//
// name - The name of the switch. ("Away Mode")
// sensorNames - Array of names for each sensor to be created. (["Trigger 1"])
// sensors - Array of per-sensor information.
// minOffTime - Minimum off time (seconds). (300)
// maxOffTime - Maximum off time (seconds). (1800)
// minOnTime - Minimum on time (seconds). (1800)
// maxOnTime - Maximum off time (seconds). (3600)
// startTime - Time at which triggers should start to fire (hh:mm|sunrise|sunset). (00:00)
// endTime - Time at which triggers should stop firing (hh:mm|sunrise|sunset). (23:59)
// activeTimes - Array of start/end times (see startTime/endTime)
// location - lat/long location to compute sunrise/sunset from ({lat: x, long: y}). ({lat: 0, long: 0})
// offset - Offset information for sunrise/sunset ({sunrise: mins, sunset: mins}). ({sunrise: 0, sunset: 0})
//
// User location can be found with:
// https://www.gps-coordinates.net/
//
////////////////////////////////////////////////////////////////////////////////
const storage = require('node-persist');
const TimeFormat = require('hh-mm-ss');
const SunCalc = require('suncalc');
var padStart = require('string.prototype.padstart');
var Homebridge, Service, Characteristic;
module.exports = function(homebridge) {
Homebridge = homebridge;
Service = homebridge.hap.Service;
Characteristic = homebridge.hap.Characteristic;
homebridge.registerAccessory("homebridge-away-mode", "AwayMode", AwayMode);
}
class AwayMode {
//
// Constructor. Create control switch and sensors.
//
constructor(log, config) {
// Shim padStart if it is unavailable (early versions of node)
if (!String.prototype.padStart) {
padStart.shim();
}
this.log = log;
this.name = config["name"] || "Away Mode";
this.sensorNames = config["sensorNames"] || ["Trigger 1"];
this.sensors = config["sensors"] || [];
// Time in seconds
this.minOffTime = config["minOffTime"] || 300; // 5 min (secs)
this.maxOffTime = config["maxOffTime"] || 1800; // 30 min (secs)
this.minOnTime = config["minOnTime"] || 1800; // 30 min (secs)
this.maxOnTime = config["maxOnTime"] || 3600; // 60 min (secs)
this.startTime = config["startTime"] || "00:00"; // hh:mm|sunrise|sunset
this.endTime = config["endTime"] || "23:59"; // hh:mm|sunrise|sunset
this.activeTimes = config["activeTimes"] || [{start:this.startTime, end:this.endTime}];
// https://google-developers.appspot.com/maps/documentation/utils/geocoder/
this.location = config["location"] || {lat:0, long:0};
this.offset = config["offset"] || {sunrise:0, sunset:0}; // 0 min (mins)
this.maxOnSensors = config["maxOnSensors"] || 0; // No max
// Multiplier to get to timer values (in milliseconds)
this.multiplier = 1000;
this.isSwitchOn = false;
// Create switch to turn on/off away mode
this.serviceSwitch = new Service.Switch(this.name);
this.log("Switch: " + this.name);
this.serviceSwitch
.getCharacteristic(Characteristic.On)
.on('get', function(callback) {
callback(null, this.isSwitchOn);
}.bind(this))
.on('set', this.setOn.bind(this));
// Check for sensors. If they don't exist, we
// will create them here. For backwards compatibility.
if (this.sensors.length === 0) {
for (let x = 0; x < this.sensorNames.length; x++) {
this.sensors[x] = {
name: this.sensorNames[x]
}
}
}
// Create motion sensors that will fire randomly
this.serviceMotions = [];
this.serviceStates = [];
for (let x = 1; x <= this.sensors.length; x++) {
let sensor = this.sensors[x-1];
sensor.id = x - 1;
let serviceMotion = new Service.MotionSensor(sensor.name, x);
// ConfiguredName characteristic ensure that the configured name
// is exposed to HomeKit. This corrects a change in HomeKit that
// prevented the configured name from appearing properly.
serviceMotion.addOptionalCharacteristic(Characteristic.ConfiguredName);
serviceMotion.setCharacteristic(Characteristic.ConfiguredName, sensor.name);
this.sensorLogDebug(sensor, "Configuring motion sensor");
let serviceState = { "motionDetected": false };
serviceMotion.getCharacteristic(Characteristic.MotionDetected)
.on('get', function(callback) {
callback(null, serviceState.motionDetected);
}.bind(this));
this.serviceMotions.push(serviceMotion);
this.serviceStates.push(serviceState);
// populate sensor defaults if necessary
sensor.minOffTime = sensor.minOffTime || this.minOffTime;
sensor.maxOffTime = sensor.maxOffTime || this.maxOffTime;
sensor.minOnTime = sensor.minOnTime || this.minOnTime;
sensor.maxOnTime = sensor.maxOnTime || this.maxOnTime;
sensor.offset = sensor.offset || this.offset;
// summarise timer for each sensor (rather than dumping whole JSON object)
this.sensorLog(sensor, "Timer On: " + sensor.minOnTime + "-" + sensor.maxOnTime +
"s, Off: " + sensor.minOffTime + "-" + sensor.maxOffTime + "s");
// populate active times if necessary
sensor.activeTimesForSensor = sensor.activeTimesForSensor || this.activeTimes;
sensor.activeSeconds = new Array(sensor.activeTimesForSensor.length);
// This call computes side-effects - activeSeconds
this.computeStartEndTimesForSensor(x-1);
}
this.log.debug(`Sensors: ${JSON.stringify(this.sensors)}`);
// Restore previous state as necessary
setTimeout(this.restore.bind(this));
}
async restore() {
// initialize local storage
await storage.init({dir: Homebridge.user.persistPath(), forgiveParseErrors: true});
// retrieve previous state - main switch
let active = await storage.getItem('active');
// main switch previously on
if (active === 'true') {
this.log('Restore switch state to on');
this.serviceSwitch.updateCharacteristic(Characteristic.On, true);
// set the restore flag on sensors so we check them on first activation
for (let i=0; i<this.sensors.length; i++) {
this.sensors[i].restore = true;
}
this.setOn(true, undefined);
}
}
//
// Convert seconds to "hh:mm:ss" for display
//
secondsToHourMinSec(timeInSeconds) {
let r = 0;
// hours
let hours = Math.floor(timeInSeconds / 3600);
// minutes
r = timeInSeconds % 3600
let mins = Math.floor(r / 60);
// seconds
r = r % 60;
return '' + hours.toString().padStart(2, '0') + ':' +
mins.toString().padStart(2, '0') + ':' +
r.toString().padStart(2, '0');
}
//
// Log sensor information.
//
sensorLog(sensor, message) {
this.log(sensor.name + " (" + sensor.id + "): " + message);
}
//
// More verbose sensor logging when using homebridge debug mode -D
//
sensorLogDebug(sensor, message) {
this.log.debug(sensor.name + " (" + sensor.id + "): " +message);
}
//
// Compute the seconds from midnight for the given
// time specification. Time is either 'hh:mm', 'sunrise', or 'sunset'.
// 'sc' contains sunrise/sunset info. 'offset' only applies to
// sunrise/sunset calculations.
//
computeSecondsFromMidnight(sc, time, offset) {
let seconds = 0;
let dynamic = false;
if (time === 'sunrise' || time === 'sunset') {
dynamic = true;
let t = 0; // base time - sunrise or sunset
let o = 0; // offset from t
if (time === 'sunrise') {
t = sc.sunrise;
o = offset.sunrise * 60; // to seconds
} else {
t = sc.sunset;
o = offset.sunset * 60; // to seconds
}
seconds = (t.getHours() * 3600) + (t.getMinutes() * 60) + t.getSeconds() + o;
} else {
seconds = TimeFormat.toS(time, 'hh:mm');
}
return { seconds: seconds, dynamic: dynamic };
}
//
// Compute start and end times. If dynamic values present (sunrise | sunset),
// this method will be called at midnight every day.
//
// *** Side effects ***
// Sets the values for sensor.activeSeconds
//
computeStartEndTimesForSensor(id) {
const sensor = this.sensors[id];
let dynamic = false;
let times = SunCalc.getTimes(new Date(), this.location.lat, this.location.long);
// Examine each of the defined ranges
for (let i=0; i<sensor.activeTimesForSensor.length; i++) {
let startInfo = this.computeSecondsFromMidnight(times, sensor.activeTimesForSensor[i].start, sensor.offset);
let endInfo = this.computeSecondsFromMidnight(times, sensor.activeTimesForSensor[i].end, sensor.offset);
dynamic = dynamic || startInfo.dynamic || endInfo.dynamic;
sensor.activeSeconds[i] = {start: startInfo.seconds, end: endInfo.seconds};
this.sensorLog(sensor,
"Active range " + i + " Start: " + this.secondsToHourMinSec(sensor.activeSeconds[i].start) +
" End: " + this.secondsToHourMinSec(sensor.activeSeconds[i].end)
);
}
// Set a timer to expire at midnight so we can recalculate the
// values of sunrise & sunset (if needed)
if (dynamic) {
let today = new Date();
let tommorow = new Date(today.getFullYear(),today.getMonth(),today.getDate()+1);
let timeToMidnight = (tommorow-today)+5000; // +5sec to push it past
this.sensorLogDebug(sensor, "Recompute in: " + timeToMidnight/1000);
let timer = setTimeout(function() {
this.computeStartEndTimesForSensor(id);
}.bind(this), timeToMidnight);
}
}
//
// Representation of current time in seconds.
//
currentSeconds() {
let now = new Date();
return (now.getHours() * 3600) + (now.getMinutes() * 60) + now.getSeconds();
}
//
// Return a count of the number of sensors currently on.
//
countOfOnSensors() {
let count = 0;
for (let id = 0; id < this.sensors.length; id++) {
if (this.serviceStates[id].motionDetected) {
count++;
}
}
return count;
}
//
// Return true if the sensor should be turned on. I.e., now falls
// within the startSeconds / endSeconds range.
//
sensorOnTime(sensor) {
let currentSeconds = this.currentSeconds();
let turnOn = false;
// Examine each of the defined ranges
for (let i=0; i<sensor.activeSeconds.length && turnOn == false; i++) {
let activeInterval = sensor.activeSeconds[i];
this.sensorLogDebug(sensor,
"Active range " + i + " [" + this.secondsToHourMinSec(activeInterval.start) + " - " +
this.secondsToHourMinSec(activeInterval.end) + "] --> " + this.secondsToHourMinSec(currentSeconds)
);
// start / end w/in same day, e.g. 08:00 - 20:00
if (activeInterval.start <= activeInterval.end) {
turnOn = (activeInterval.start < currentSeconds) && (currentSeconds < activeInterval.end);
}
// start / end span days, e.g. 20:00 - 08:00
else {
turnOn = (activeInterval.start < currentSeconds) || (currentSeconds < activeInterval.end);
}
// may turn on during this period, evaluate for maximum activiations
if (turnOn) {
// active period has changed, start counting activations
if ((typeof sensor.activePeriod === 'undefined') || (sensor.activePeriod != i)) {
sensor.activeTimesForSensor[i].activationCount = 0;
sensor.activePeriod = i;
}
// same active period, check for maximum activations (if specified),
// if max activations has been reached, then don't turn it on,
// but we stay in the same period
else if (sensor.activeTimesForSensor[i].maxActivations &&
sensor.activeTimesForSensor[i].activationCount >= sensor.activeTimesForSensor[i].maxActivations) {
this.sensorLog(sensor, `Max activations: ${sensor.activeTimesForSensor[i].maxActivations}`);
turnOn = false;
break;
}
// check to see if we have reached the maximum number of on sensors
if (this.maxOnSensors && (this.countOfOnSensors() >= this.maxOnSensors)) {
this.sensorLog(sensor, `Max on sensors: ${this.maxOnSensors}`);
turnOn = false;
break;
}
} else {
delete sensor.activePeriod;
}
}
return turnOn;
}
//
// Turn sensor on after a random amount of off time.
//
startOnTimer(id) {
const serviceMotion = this.serviceMotions[id];
const serviceState = this.serviceStates[id];
const sensor = this.sensors[id];
let time = parseInt(Math.floor(Math.random() * (sensor.maxOffTime - sensor.minOffTime + 1) + sensor.minOffTime));
let currentSeconds = this.currentSeconds();
this.sensorLogDebug(sensor, "Starting on timer, delay: " + time +
" [" + this.secondsToHourMinSec(currentSeconds + time) + "]");
serviceState.timeout = setTimeout(function() {
// Only turn on sensors during allowed times
if (this.sensorOnTime(sensor)) {
this.sensorLogDebug(sensor, "Turning motion on");
this.setSensorOn(id, true);
sensor.activeTimesForSensor[sensor.activePeriod].activationCount++;
}
this.startOffTimer(id);
}.bind(this), time*this.multiplier);
}
//
// Time (in seconds) until end of active interval. 0 if not in
// an active interval.
//
timeUntilEndOfInterval(sensor) {
let currentSeconds = this.currentSeconds();
let time = 0;
let activeInterval = sensor.activeSeconds[sensor.activePeriod];
// start / end w/in same day, e.g. 08:00 - 20:00
if (activeInterval.start <= activeInterval.end) {
if ((activeInterval.start < currentSeconds) && (currentSeconds < activeInterval.end)) {
time = activeInterval.end - currentSeconds;
}
}
// start / end span days, e.g. 20:00 - 08:00
else {
if ((activeInterval.start < currentSeconds) || (currentSeconds < activeInterval.end)) {
const secondsPerDay = 86400;
// before midnight --> seconds remaining in current day + seconds in next day
if (activeInterval.start < currentSeconds) {
time = (secondsPerDay - currentSeconds) + activeInterval.end;
}
// after midnight --> seconds remaining in current day
else {
time = activeInterval.end - currentSeconds;
}
}
}
return time;
}
//
// Compute the off time at which to turn the given sensor off.
//
computeOffTime(sensor) {
let time = parseInt(Math.floor(Math.random() * (sensor.maxOnTime - sensor.minOnTime + 1) + sensor.minOnTime));
// check for a hard off time and adjust as needed
if ((typeof sensor.activePeriod !== 'undefined') && sensor.activeTimesForSensor[sensor.activePeriod].absolute) {
let maxTime = this.timeUntilEndOfInterval(sensor);
if (time > maxTime) {
time = maxTime;
}
}
return time;
}
//
// Turn sensor off after a random amount of on time.
//
startOffTimer(id) {
const serviceMotion = this.serviceMotions[id];
const serviceState = this.serviceStates[id];
const sensor = this.sensors[id];
let time = this.computeOffTime(sensor);
let currentSeconds = this.currentSeconds();
this.sensorLogDebug(sensor,
"Starting off timer, delay: " + time + " [" + this.secondsToHourMinSec(currentSeconds + time) + "]"
);
if (serviceState.motionDetected) { // seems more compact and intuitive to log like this
this.sensorLog(sensor,"Motion on for " + time + "s until [" + this.secondsToHourMinSec(currentSeconds + time) + "]");
}
serviceState.timeout = setTimeout(function() {
this.sensorLogDebug(sensor, "Turning motion off");
this.setSensorOn(id, false);
this.startOnTimer(id);
}.bind(this), time*this.multiplier);
}
//
// Initialize the sensor.
//
async startSensor(id) {
let sensor = this.sensors[id];
this.sensorLog(sensor, "Starting sensor");
// restore flag set for this sensor - check it - start up only
if (this.sensors[id].restore) {
let isOn = await storage.getItem(sensor.name);
// we only do this once
delete sensor.restore;
// On when we quit and still can be on
if (isOn === 'true' && this.sensorOnTime(sensor)) {
this.sensorLog(sensor, "Restore sensor to on");
// restore sensor to motion detected
this.sensorLog(sensor, "Turning motion on");
this.setSensorOn(id, true);
sensor.activeTimesForSensor[sensor.activePeriod].activationCount++;
// start 'off' timer
this.startOffTimer(id);
}
// On when we quit, but shouldn't be on now
// Note: We trigger it on first, then turn it off,
// otherwise the off won't be sent out because
// the homebridge/hap (?) framework thinks it's off.
else if (isOn === 'true') {
this.sensorLog(sensor, "Turning motion off");
// turn it on first
this.setSensorOn(id, true);
// then turn it off after delay and start on timer
setTimeout(function(idx, on) {
this.setSensorOn(idx, on);
this.startOnTimer(idx);
}.bind(this), 15000, id, false);
}
// Off when we quit - delay before turning on
else {
this.startOnTimer(id);
}
}
// standard start for sensor - delay before turning on
else {
this.startOnTimer(id);
}
}
//
// Shut down the sensor. Clear timeouts. Turn if off.
//
stopSensor(id) {
let sensor = this.sensors[id];
this.sensorLog(sensor, "Stopping sensor");
const serviceState = this.serviceStates[id];
const motionDetected = serviceState.motionDetected;
const timeout = serviceState.timeout;
// Timeout currently set, cancel
if (timeout) {
this.sensorLogDebug(sensor, "Stopping timer");
clearTimeout(timeout);
delete serviceState.timeout;
}
// Sensor is currently on, turn it off
if (motionDetected) {
this.sensorLogDebug(sensor, "Turning motion off");
this.setSensorOn(id, false);
}
// Reset the active period
if (typeof sensor.activePeriod !== 'undefined') {
delete sensor.activePeriod;
}
}
//
// Activate or deactivate a sensor.
//
async setSensorOn(id, on) {
this.serviceMotions[id].updateCharacteristic(Characteristic.MotionDetected, on);
this.serviceStates[id].motionDetected = on;
await storage.setItem(this.sensors[id].name, on ? 'true' : 'false');
}
//
// Turn switch on or off.
//
async setOn(on, callback) {
// Store the new state
await storage.setItem('active', on ? 'true' : 'false');
// Turn away mode on, currently off
if (on && !this.isSwitchOn) {
this.log("Switch turned on");
//Turn on the switch
this.isSwitchOn = true;
// Turn on the sensors
for (let x = 0; x < this.sensors.length; x++) {
this.startSensor(x);
}
}
// Turn away mode off, currently on
else if (!on && this.isSwitchOn) {
this.log("Switch turned off");
// Turn off the switch
this.isSwitchOn = false;
// Turn off the sensors
for (let x = 0; x < this.sensors.length; x++) {
this.stopSensor(x);
}
}
callback && callback();
}
//
// Share services that have been created.
//
getServices() {
return [this.serviceSwitch, ...this.serviceMotions];
}
}