/**
* Copyright (c) 2022 Florian Hotze
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
/**
* Alerting namespace
*
* This namespace provides alerting rules, e.g. for open windows on rain.
* @namespace rulesx.alerting
*/
const { actions, rules, items, triggers } = require('openhab');
const { TimerMgr } = require('openhab_rules_tools');
const { getRoofwindowOpenLevel } = require('../itemutils');
/**
* Get the temperature difference from the temperature in a room to the outside temperature.
*
* The temperature's Item name must be: ${roomName}${temperatureItemSuffix}.
* @private
* @param {string} roomName name of room
* @param {string} outsideTemperatureItem outside temperature Item name
* @param {string} [temperatureItemSuffix=_Temperatur] string to append to the roomName to get the temperatur Item's name
* @returns {number|null} temperature difference (outside-inside) or null if no inside temperature is available
*/
const getTemperatureDifferenceInToOut = (roomName, outsideTemperatureItem, temperatureItemSuffix = '_Temperatur') => {
const temperatureItem = items.getItem(roomName + temperatureItemSuffix, true);
if (temperatureItem == null) return null;
const insideTemperature = parseFloat(temperatureItem.state);
const outsideItem = items.getItem(outsideTemperatureItem, true);
if (outsideItem == null) return null;
const outsideTemperature = parseFloat(outsideItem.state);
return outsideTemperature - insideTemperature;
};
/**
* @typedef {Object} rainAlarmConfig configuration for rain alarm
* @memberof rulesx.alerting
* @property {string} rainalarmItemName name of the rain alarm Item
* @property {string} [rainalarmActiveState=OPEN] state of the Item for an active alarm, all other states (including `UNDEF`, `NULL`) are considered as alarm inactive
* @property {string} windspeedItemName name of the wind speed Item
* @property {string} contactGroupName name of the contact group to monitor
* @property {string[]} ignoreList list of contact Item names to ignore
* @property {string} roofwindowTag tag that all roofwindow contacts have for identification
* @property {number} windspeedKlLueftung wind speed threshold for an alarm on "kleine Lüftung"
* @property {number} windspeedGrLueftung wind speed threshold for an alarm on "große Lüftung"
*/
/**
* Rainalarm
*
* Issues a rain alarm notification if the given window/door is open (and wind speed is high enough).
* @memberof rulesx.alerting
*/
class Rainalarm {
/**
* Constructor to create an instance. Do not call directly, instead call {@link rulesx.getRainalarmRule}.
* @param {rainAlarmConfig} config rainalarm configuration
* @hideconstructor
*/
constructor (config) {
if (typeof config.rainalarmItemName !== 'string') {
throw Error('rainalarmItemName is not supplied ot not string!');
}
if (typeof config.ignoreList !== 'object' || config.ignoreList === null) {
throw Error('contactGroupName is not supplied or is not Array!');
}
if (typeof config.roofwindowTag !== 'string') {
throw Error('roofwindowTag is not supplied or is not string!');
}
if (!config.rainalarmActiveState) config.rainalarmActiveState = 'OPEN';
this.config = config;
}
/**
* Sends a rainalarm notification for a roowindow.
* @private
* @param {string} baseItemName base of the Items names, e.g. Florian_Dachfenster
* @param {number} windspeed current windspeed
*/
alarmRoofwindow (baseItemName, windspeed) {
console.info(`Checking rainalarm for roofwindow "${baseItemName}" ...`);
const state = getRoofwindowOpenLevel(baseItemName);
const label = items.getItem(baseItemName + '_zu').label;
switch (state.int) {
case 1: // kleine Lüftung
if (windspeed >= this.config.windspeedKlLueftung) actions.NotificationAction.sendBroadcastNotification(`Achtung! Regenalarm: ${label} kleine Lüftung!`);
break;
case 2: // große Lüftung
if (windspeed >= this.config.windspeedGrLueftung) actions.NotificationAction.sendBroadcastNotification(`Achtung! Regenalarm: ${label} große Lüftung!`);
break;
case 4:
actions.NotificationAction.sendBroadcastNotification(`Achtung! Regenalarm: ${label} geöffnet!`);
break;
default:
break;
}
}
/**
* Send a rainalarm notification for a single contact.
* @private
* @param {string} contactItemName name of the contact Item.
*/
alarmSingleContact (contactItemName) {
const contactItem = items.getItem(contactItemName);
console.info(`Checking rainalarm for single contact "${contactItem.name}" ...`);
if (contactItem.state === 'OPEN') actions.NotificationAction.sendBroadcastNotification(`Achtung! Regenalarm: ${contactItem.label} geöffnet!`);
}
/**
* Calls the appropriate function depending on the type of window/door.
* Do NOT call directly, instead use {@link rulesx.alerting.createRainAlarmRule}.
*
* @private
* @param {string} itemname name of the Item
* @param {number} windspeed current windspeed
*/
checkAlarm (itemname, windspeed) {
if (items.getItem(this.config.rainalarmItemName).state !== this.config.rainalarmActiveState) return;
if (!this.config.ignoreList.includes(itemname)) {
const tags = items.getItem(itemname).tags;
if (tags.includes(this.config.roofwindowTag)) {
this.alarmRoofwindow(itemname.replace('_zu', '').replace('_klLueftung', '').replace('_grLueftung', ''), windspeed);
} else {
this.alarmSingleContact(itemname);
}
}
}
}
/**
* Creates the rain alarm rule.
*
* @memberof rulesx.alerting
* @param {rainAlarmConfig} config rainalarm configuration
*/
function createRainAlarmRule (config) {
const RainalarmImpl = new Rainalarm(config);
rules.JSRule({
name: 'Rainalarm',
description: 'Sends a broadcast notification when a window is open when it rains.',
triggers: [
triggers.ItemStateChangeTrigger(config.rainalarmItemName, 'CLOSED', 'OPEN'),
triggers.GroupStateChangeTrigger(config.contactGroupName, 'CLOSED', 'OPEN')
],
execute: (event) => {
const windspeed = parseFloat(items.getItem(config.windspeedItemName).state);
if (event.itemName === config.rainalarmItemName || event.eventType === 'manual') {
console.info('Rainalarm rule is running on alarm or manual execution.');
const groupMembers = items.getItem(config.contactGroupName).members.map((item) => item.name);
for (const i in groupMembers) {
// @ts-ignore
RainalarmImpl.checkAlarm(groupMembers[i], windspeed);
}
} else if (event.itemName !== undefined) {
console.info(`Rainalarm rule is running on change of contact "${event.itemName}".`);
function timeoutFunc (itemname, windspeed) {
return () => {
// @ts-ignore
RainalarmImpl.checkAlarm(itemname, windspeed);
};
}
setTimeout(timeoutFunc(event.itemName, windspeed), 2000);
}
},
id: `rainalarm-for-${config.contactGroupName}`,
tags: ['@hotzware/openhab-tools', 'getRainalarmRule', 'Alerting']
});
}
/**
* @typedef {Object} heatOrFrostAlarmConfig configuration for rainalarm
* @memberof rulesx.alerting
* @property {string} type alarm type, either `heat` or `frost`
* @property {string} alarmLevelItem name of Item that holds the alarm level
* @property {string} outsideTemperatureItem name of outside temperature Item
* @property {string} roomTemperatureItemSuffix suffix to add to the room's name to get the temperature Item's name
* @property {string} contactGroupName name of the contact group to monitor
* @property {string[]} ignoreList list of contact Item names to ignore
* @property {string} roofwindowTag tag that all roofwindow contacts have for identification
* @property {number} tempTreshold Temperature treshold, for difference between inside temp to outside. Example: -2 means at least 2 degrees lower temp on the outside.
* @property {Object} notification notification to send
* @property {Object} notification.alarm alarm notification
* @property {string} notification.alarm.title
* @property {string} notification.alarm.message
* @property {Object} notification.warning warning notification
* @property {string} notification.warning.title
* @property {string} notification.warning.message
* @property {Object} time Times until an alarm is sent.
* @property {number} time.open
* @property {number} time.halfOpen window is tilted or roofwindow is on "große Lüftung"
* @property {number} time.klLueftung roofwindow is on "kleine Lüftung"
* @property {number} time.addForWarning Time to add when it's only a warning.
*/
/**
* Heat- / Frostalarm
*
* Issues a heat- or frostalarm notification if the given window/door is open, it is hot or cold enough and enough time passed by.
*
* Before a noficiation is sent, the logic checks for the following conditions:
* - contact is open
* - alarm level is not `0`
* - configured temperature difference between inside and outside is reached
*
* Then logic decides depending on the alarm level and the openess level of the window/door, whether to send warning or alarm and which time to choose.
*
* Item naming scheme is required:
* - for roofwindows see {@link itemutils.getRoofwindowOpenLevel}
* - generally: roomname + `_`... for contacts and then roomname + `_Temperatur` for the room's temperature (roomname must always be before the first `_`)
*
* This class respects an alarm level (hold by an Item) which is an integer:
* - `0` for no alarm
* - `0` < x < `4` for warning
* - `4` for alarm
* You may use a rule to set the alarm level depending on the outside temperature.
* @memberof rulesx.alerting
*/
class HeatFrostalarm {
/**
* Constructor to create an instance. Do not call directly, instead call {@link rulesx.alerting.createHeatAlarmRule} or {@link rulesx.alerting.createFrostAlarmRule}.
* @param {heatOrFrostAlarmConfig} config configuration
* @param {TimerMgr} timerMgr instance of {@link TimerMgr}
* @hideconstructor
*/
constructor (config, timerMgr) {
if (typeof config.alarmLevelItem !== 'string') throw Error('alarmLevelItem is not supplied or is not string!');
if (typeof config.outsideTemperatureItem !== 'string') throw Error('outsideTemperatureItem is not supplied or is not string!');
if (typeof config.ignoreList !== 'object' || config.ignoreList === null) throw Error('contactGroupName is not supplied or is not Array!');
if (typeof config.roofwindowTag !== 'string') throw Error('roofwindowTag is not supplied or is not string!');
if (typeof config.tempTreshold !== 'number') throw Error('tempTreshold is not supplied or is not string!');
if (typeof config.notification !== 'object' || config.notification === null) throw Error('notification is not supplied or is not object!');
if (typeof config.time !== 'object' || config.time === null) throw Error('time is not supplied or is not object!');
if (typeof config.time.open !== 'number') throw Error('time.open is not supplied or is not number!');
this.config = config;
this.timerMgr = timerMgr;
}
/**
* Function generator for the function to run when the timer expires.
* @private
* @param {string} contactItem name of contact item
* @returns {Function} function to run when the timer expires
*/
timerFuncGenerator (contactItem) {
return () => {
this.scheduleOrPerformAlarm(contactItem, true);
};
}
/**
* Schedules a timer for a given contact or sends the notification.
* Checks whether all conditions are met.
* @private
* @param {string} contactItem name of contact Item
* @param {boolean} [calledOnExpire=false] if true, send notification
* @param {number} [time] time in minutes until timer expires, not required if `calledOnExpire === true`
*/
scheduleOrPerformAlarm (contactItem, calledOnExpire = false, time) {
console.info(`checkContact: Checking ${contactItem} (called from expired timer: ${calledOnExpire}).`);
// If contact is closed, return false.
if (items.getItem(contactItem).state === 'CLOSED') {
if (this.timerMgr.hasTimer(contactItem)) {
this.timerMgr.cancel(contactItem);
return console.info(`checkContact: ${contactItem} is closed, cancelling timer.`);
}
return console.info(`checkContact: ${contactItem} is closed, returning.`);
}
const alarmLevel = parseInt(items.getItem(this.config.alarmLevelItem).state);
// If alarmLevel indicates no alarm or warning, return false.
if (alarmLevel === 0) return console.info('checkContact: No alarms or warning should be sent, returning.');
const roomname = contactItem.split('_')[0];
const temperatureDifferenceInOut = getTemperatureDifferenceInToOut(roomname, this.config.outsideTemperatureItem, this.config.roomTemperatureItemSuffix);
const tresholdReached = (temperatureDifferenceInOut == null) ? true : (this.config.tempTreshold < 0) ? (temperatureDifferenceInOut <= this.config.tempTreshold) : (temperatureDifferenceInOut >= this.config.tempTreshold);
// If tempTreshold is not reached, return false.
if (tresholdReached === false) return console.info(`checkContact: Temperature treshold for ${contactItem} (${this.config.type}) not reached, returning.`);
// Send notification if called on expire of timer.
if (calledOnExpire === true) {
console.info(`Timer for ${contactItem} (${this.config.type}) expired, sending notification.`);
if (alarmLevel === 4) return actions.NotificationAction.sendBroadcastNotification(`${this.config.notification.alarm.title}${items.getItem(contactItem).label}${this.config.notification.alarm.message}`);
return actions.NotificationAction.sendBroadcastNotification(`${this.config.notification.warning.title}${items.getItem(contactItem).label}${this.config.notification.warning.message}`);
}
// If not called on expire of timer, schedule timer.
// Brackets around time calculation are required, otherwise the numbers are appended as strings and not added
const timerTime = (alarmLevel !== 4) ? 'PT' + (time + this.config.time.addForWarning) + 'M' : 'PT' + time + 'M';
if (this.timerMgr.hasTimer(contactItem)) {
console.info(`checkContact: Timer for ${contactItem} (${this.config.type}) already exists, skipping!`);
} else {
this.timerMgr.check(contactItem, timerTime, this.timerFuncGenerator(contactItem));
console.info(`checkContact: Created timer for ${contactItem} (${this.config.type}) with time ${timerTime}.`);
}
}
getTimeForRoofwindow (contactItem) {
const state = getRoofwindowOpenLevel(contactItem.replace('_zu', '').replace('_klLueftung', '').replace('_grLueftung', ''));
switch (state.int) {
case 1: // kleine Lüftung
return this.config.time.klLueftung;
case 2: // große Lüftung
return this.config.time.halfOpen;
default:
return this.config.time.open;
}
}
/**
* Calls the alarm logic with the appropriate parameters depending on the type of window/door.
* Do NOT call directly, instead use {@link rulesx.alerting.createHeatAlarmRule} or {@link rulesx.alerting.createFrostAlarmRule}.
*
* @private
* @param {string} itemName name of the Item
*/
checkAlarm (itemName) {
// The alarm level must not be checked here, otherwise scheduleOrPerformAlarm can't cancel a timer.
if (!this.config.ignoreList.includes(itemName)) {
const tags = items.getItem(itemName).tags;
if (tags.includes(this.config.roofwindowTag)) {
const time = this.getTimeForRoofwindow(itemName.replace('_zu', '').replace('_klLueftung', '').replace('_grLueftung', ''));
this.scheduleOrPerformAlarm(itemName, false, time);
} else {
this.scheduleOrPerformAlarm(itemName, false, this.config.time.open);
}
}
}
}
/**
* Create the heat alarm rule.
*
* @memberof rulesx.alerting
* @param {heatOrFrostAlarmConfig} config alarm configuration
*/
function createHeatAlarmRule (config) {
const timerMgr = TimerMgr();
const HeatalarmImpl = new HeatFrostalarm(config, timerMgr);
rules.JSRule({
name: 'Heatalarm',
description: 'Send a broadcast notficiation when a window/door is too long open when it is too warm.',
triggers: [
triggers.ItemStateChangeTrigger(config.outsideTemperatureItem),
triggers.GroupStateChangeTrigger(config.contactGroupName)
],
execute: (event) => {
// The alarm level must not be checked here, otherwise scheduleOrPerformAlarm can't cancel a timer.
if (event.itemName === config.outsideTemperatureItem || event.eventType === 'manual') {
console.info('Heatalarm rule is running on temperature change or manual excution.');
const groupMembers = items.getItem(config.contactGroupName).members.map((item) => item.name);
for (const i in groupMembers) {
// @ts-ignore
HeatalarmImpl.checkAlarm(groupMembers[i]);
}
} else if (event.itemName !== undefined) {
console.info(`Heatalarm rule is running on change, Item ${event.itemName}.`);
function timeoutFunc (itemname) {
return () => {
// @ts-ignore
HeatalarmImpl.checkAlarm(itemname);
};
}
setTimeout(timeoutFunc(event.itemName), 2000);
}
},
id: `heatalarm-for-${config.contactGroupName}`,
tags: ['@hotzware/openhab-tools', 'getHeatalarmRule', 'Alerting']
});
}
/**
* Create the frostalarm rule.
*
* @memberof rulesx.alerting
* @param {heatOrFrostAlarmConfig} config alarm configuration
*/
function createFrostAlarmRule (config) {
const timerMgr = TimerMgr();
const FrostalarmImpl = new HeatFrostalarm(config, timerMgr);
rules.JSRule({
name: 'Frostalarm',
description: 'Send a broadcast notficiation when a window/door is too long open when it is too cold.',
triggers: [
triggers.ItemStateChangeTrigger(config.outsideTemperatureItem),
triggers.GroupStateChangeTrigger(config.contactGroupName)
],
execute: (event) => {
// The alarm level must not be checked here, otherwise scheduleOrPerformAlarm can't cancel a timer.
if (event.itemName === config.outsideTemperatureItem || event.eventType === 'manual') {
console.info('Frostalarm rule is running on temperature change or manual execution.');
const groupMembers = items.getItem(config.contactGroupName).members.map((item) => item.name);
for (const i in groupMembers) {
// @ts-ignore
FrostalarmImpl.checkAlarm(groupMembers[i]);
}
} else if (event.itemName !== null) {
console.info(`Frostalarm rule is running on change, Item ${event.itemName}.`);
function timeoutFunc (itemname) {
return () => {
// @ts-ignore
FrostalarmImpl.checkAlarm(itemname);
};
}
setTimeout(timeoutFunc(event.itemName), 2000);
}
},
id: `frostalarm-for-${config.contactGroupName}`,
tags: ['@hotzware/openhab-tools', 'getFrostalarmRule', 'Alerting']
});
}
module.exports = {
createRainAlarmRule,
createHeatAlarmRule,
createFrostAlarmRule
};