openhab-automator
Version:
A utility toolkit for building automation controllers in openHAB (JS Scripting). The package creates a control Item `Automator_<name>` for one or more of your Items, supports a manual override of automation for N minutes with auto-restore, debouncing, per
616 lines (473 loc) • 19.5 kB
JavaScript
const { rules, items, triggers, cache, actions } = require('openhab');
// ---------- helpers ----------
const UUID = () => Java.type('java.util.UUID').randomUUID().toString();
const cronEach = (sec, label) => {
// openHAB Quartz (7 полів): сек, хв, год, день, місяць, день-тижня, рік
if (sec > 0 && sec < 60) return triggers.GenericCronTrigger(`0/${sec} * * ? * * *`, label);
if (sec >= 60 && sec < 3600) return triggers.GenericCronTrigger(`0 0/${Math.round(sec / 60)} * ? * * *`, label);
return null;
};
class Manager {
#config;
#manualValue = 'OFF';
#item;
#rule;
#handlers = [];
#names;
#callbacks = {};
constructor(config, ...names) {
this.#names = names;
if (!items.existsItem('gAutomator')) {
items.addItem({
type: 'Group',
name: 'gAutomator',
});
}
if (!items.existsItem('Automator_Heatbeat')) {
items.addItem({
type: 'Number',
name: 'Automator_Heatbeat',
});
}
this.#config = Object.assign(config, {
type: 'String',
groups: ['gAutomator'],
metadata: {
/*automation: {
value: null,
config: {
}
},
updated: {
value: null,
config: {
}
},*/
stateDescription: {
config: {
pattern: '%d%%'
}
},
commandDescription: {
config: {
pattern: '%d%%'
}
}
},
tags: ['Application']
});
this.onActivate = function (callback) {
this.#callbacks.onActivate = callback.bind(this);
return this;
};
this.onDisable = function (callback) {
this.#callbacks.onDisable = callback.bind(this);
return this;
};
this.onLog = function (callback) {
this.#callbacks.onLog = callback.bind(this);
return this;
};
this.label = function (label, short) {
this.#config.label = label;
if (short !== undefined) {
this.#config.metadata.stateDescription.value = short;
}
if (this.#rule) this.#init();
return this;
}
this.log = function (...args) {
var logger = log(this.name);
logger.info(args);
if (this.#callbacks.onLog) {
this.#callbacks.onLog.call(this, ...args);
}
}
}
#init() {
let item_config = { ...this.#config };
item_config.name = items.safeItemName(`Automator_${this.name}`);
let t = [
cronEach(60),
triggers.ItemStateChangeTrigger(item_config.name)
];
for (const name of this.for) {
if (items.existsItem(name)) {
t.push(triggers.ItemCommandTrigger(name));
for (const g of items.getItem(name).groupNames) {
item_config.groups.push(g);
}
}
}
const item = items.getItem(item_config.name, true);
let is_new = false;
if (item !== null) { // Item already existed
items.removeItem(item_config.name);
} else {
is_new = true;
}
this.#item = items.addItem(item_config, true);
this.#rule = rules.JSRule({
name: `Automator ${this.name} controller`,
description: "Set meta auto timer",
triggers: t,
execute: (event) => {
// check if manual
if (event.eventType == 'command') {
const cache_name = items.safeItemName(`${this.id}_${event.itemName}_automator`);
let found = cache.private.get(cache_name);
let is_manual = true;
if (found) {
let v = JSON.parse(found);
if (v.command == event.receivedCommand) {
is_manual = false;
}
}
cache.private.remove(cache_name);
if (is_manual) {
for (const h of this.#handlers) {
h.enabled(false);
}
this.manual();
}
return;
}
const CACHE_KEY = this.id + '-automator-expire-timeoutId';
let update = false;
if (event.eventType === 'time') {
// cron
update = !this.automated && this.#item.state !== 'OFF';
} else if (event.eventType == 'change') {
let activate = (event.newState === 'ON');
if (activate && this.#callbacks.onActivate) {
this.#callbacks.onActivate.call(this, event);
}
if (!activate && this.#callbacks.onDisable) {
this.#callbacks.onDisable.call(this, event);
}
for (const h of this.#handlers) {
h.enabled(activate);
}
if (cache.private.exists(CACHE_KEY)) {
clearTimeout(cache.private.remove(CACHE_KEY));
}
let is_numeric = event.newState.trim() !== '' && !isNaN(event.newState);
if (is_numeric) {
update = true;
let seconds = event.newState * 60;
const timeoutId = setTimeout((item_name) => {
items.getItem(item_name).postUpdate('ON');
}, seconds * 1000, event.itemName);
cache.private.put(CACHE_KEY, timeoutId);
}
}
if (update) {
const i = this.#item.persistence;
// time since last not automated state
if (i !== undefined) {
let previous_state = i.previousState(false); let ts = previous_state ? previous_state.timestamp : null;
while (previous_state) {
if (previous_state.state === 'ON' || previous_state.state === 'OFF') {
let seconds = Math.round(ts.getMillisFromNow() / -1000);
let elapsed = this.#item.numericState * 60 - seconds;
if (!cache.private.exists(CACHE_KEY)) {
// reinit timer
cache.private.put(CACHE_KEY, setTimeout((item_name) => {
items.getItem(item_name).postUpdate('ON');
}, elapsed * 1000, this.#item.name));
}
const hrs = Math.floor(elapsed / 3600);
const mins = Math.floor((elapsed % 3600) / 60);
const secs = elapsed % 60;
if (hrs > 0) {
elapsed = [
hrs.toString().padStart(2, '0'),
'h',
mins.toString().padStart(2, '0'),
'm'
].join('');
} else {
elapsed = [
mins.toString().padStart(2, '0'),
'm'
].join('');
}
items.metadata.replaceMetadata(this.#item, 'automator', `${elapsed}`, {
elapsed: `${seconds}`,
ts: ts.toString(),
});
break;
} else {
ts = previous_state.timestamp;
}
previous_state = i.persistedState(ts);
}
items.getItem('Automator_Heatbeat').postUpdate(String(Date.now())); // heartbeat
} else {
console.log('not persisted?');
}
}
},
tags: ['Automator'],
id: this.id,
overwrite: true
});
if (is_new) {
this.#item.sendCommand('OFF');
}
return this;
}
get id() {
return (this.#rule !== undefined) ? this.#rule.getUID() : UUID();
}
get name() {
return this.#names.join('-');
}
get for() {
return this.#names;
}
get automated() {
return this.#item.state == 'ON';
}
groups(list) {
this.#config.groups.push(...list);
if (this.#rule) this.#init();
return this;
}
options(map, manual) {
let options = "";
// required opyons
for (const [key, value] of Object.entries({
'ON': actions.Transformation.transform('MAP', 'automation.map', 'ON'),
'OFF': actions.Transformation.transform('MAP', 'automation.map', 'OFF'),
})) {
options = options.concat(key, "=", value, ",");
};
for (const [key, value] of Object.entries(map)) {
options = options.concat(key, "=", value, ",");
};
if (options.length > 0) {
this.#config.metadata.stateDescription.config.options = options.substring(0, options.length - 1);
this.#config.metadata.commandDescription.config.options = options.substring(0, options.length - 1);
}
this.#manualValue = manual.toString();
if (this.#rule) this.#init();
return this;
}
manual(mode) {
if (mode === undefined) mode = this.#manualValue;
this.#item.sendCommand((mode === undefined) ? 'OFF' : mode);
return this;
}
handler(callback) {
if (!this.#rule) this.#init();
let h = new Handler(this, callback);
this.#handlers.push(h);
return h;
}
}
class Handler {
#callback;
#manager;
#rule;
#ruleTimer;
#debounce;
#dependsOf = new Set();
#triggers = [];
#logs = [];
#pooled = false;
constructor(manager, callback) {
this.#callback = callback;
this.#manager = manager;
this.dependsOf = this.dependsOf.bind(this);
this.debounce = this.debounce.bind(this);
this.interval = this.interval.bind(this);
this.triggers = this.triggers.bind(this);
this.log = this.log.bind(this);
this.#init();
}
log(...args) {
this.#logs.push(args);
}
#init() {
const rule_id = this.#rule?.getUID?.() ?? UUID();
if (this.#triggers.length < 1) {
return this;
}
this.#pooled = false;
this.#rule = rules.JSRule({
name: `Automanager ${this.#manager.name} handle rule`,
triggers: this.#triggers,
execute: event => {
console.log(event);
if (this.#debounce > 0) {
if (event?.module !== 'debounce') {
this.#pooled = event;
console.log('pooled ' + event?.module);
return;
}
if (this.#pooled === false) {
console.log('ignore');
return;
} else event = this.#pooled;
}
let h = new Proxy(this, {
get(obj, prop) {
if (typeof prop === 'symbol') {
return obj[prop];
}
return (prop in obj) ? obj[prop] : obj.get(prop);
},
set(obj, prop, value) {
obj.auto(prop, value);
return true;
}
});
this.#logs = [];
const callback_return = this.#callback.bind(h, event, this)();
let changed = false;
if (typeof callback_return === 'string' || callback_return instanceof String) {
changed = this.auto(this.#manager.name, callback_return);
} else if (typeof callback_return === 'number') {
changed = this.auto(this.#manager.name, callback_return);
} else if (typeof callback_return === 'boolean') {
changed = this.auto(this.#manager.name, callback_return);
} else if (typeof callback_return === 'object') {
for (const [key, value] of Object.entries(callback_return)) {
changed = this.auto(key, value) || changed;
};
}
if (changed && this.#logs?.length) {
for(const l of this.#logs) {
this.#manager.log(...l);
}
this.#logs = [];
}
this.#pooled = false;
},
tags: ['Automator'],
id: rule_id,
overwrite: true
});
this.enabled(this.#manager.automated);
return this;
}
enabled(state) {
rules.setEnabled(this.#rule.getUID(), state);
if (state) rules.runRule(this.#rule.getUID(), {}, true);
}
get(name) {
const m = /^(.+)_(average|minimum|maximum|deviation|sum|changed)_(\d+[smhdw])$/.exec(name);
let v = undefined;
let item = undefined;
if (m) {
const [ , itemName, method, period ] = m;
let duration = time.Duration.parse(`PT${period.toUpperCase()}`);
const since = time.ZonedDateTime.now().minusNanos(duration.toMillis() * 1e6);
item = items.getItem(itemName);
if (method === 'average') {
v = item.persistence.averageSince(since);
} else if (method === 'minimum') {
v = item.persistence.minimumSince(since);
} else if (method === 'maximum') {
v = item.persistence.maximumSince(since);
} else if (method === 'sum') {
v = item.persistence.sumSince(since);
} else if (method === 'deviation') {
v = item.persistence.deviationSince(since);
} else if (method === 'changed') {
v = item.persistence.changedSince(since);
} else {
throw new Error(`Unsupported method: ${method}`);
}
} else {
v = item = items.getItem(name);
}
if (!this.#dependsOf.has(item.name) && !this.#manager.for.includes(item.name)) {
throw new Error(`${this.#manager.name} automator handler not depends of ${item.name}`);
}
if (typeof v === 'boolean') return v;
if (item.type === 'Switch') return v.state === 'ON';
if (item.type === 'Contact') return v.state === 'OPEN';
if (item.type.startsWith('Number:')) return v.quantityState;
if (item.type === 'Number') return v.numericState;
if (item.type === 'Dimmer') return v.numericState;
return item.state;
}
auto(name, command) {
let now = Math.round(Date.now() / 1000);
if (items.existsItem(name)) {
const cache_name = items.safeItemName(`${this.#manager.id}_${name}_automator`);
let found = cache.private.get(cache_name);
if (found) {
console.log(found);
let v = JSON.parse(found);
if (v.ts > now - 5) {
console.log('igrnore auto!' + v.ts + ' ' + now);
return;
}
}
const item = items.getItem(name);
let str_command = `${command}`;
if (typeof command === 'boolean' && item.type === 'Switch') str_command = command ? 'ON' : 'OFF';
if (typeof command === 'boolean' && item.type === 'Contact') str_command = command ? 'OPEN' : 'CLOSED';
if (typeof command === 'number' && item.type.startsWith('Number:')) {
// nothing ?
}
cache.private.put(cache_name, JSON.stringify({
name: name,
command: str_command,
ts: now
}));
if (!item.sendCommandIfDifferent(str_command)) {
cache.private.remove(cache_name);
} else {
return true;
}
}
}
triggers(...triggers) {
this.#triggers.push(...triggers);
if (this.#ruleTimer) {
clearTimeout(this.#ruleTimer);
}
this.#ruleTimer = setTimeout(function() {
this.#init();
}.bind(this), 1000);
return this;
}
dependsOf(...names) {
let t = [];
for (const name of names) {
this.#dependsOf.add(name);
t.push(triggers.ItemStateChangeTrigger(name));
};
return this.triggers(...t);
}
debounce(seconds) {
this.#debounce = seconds;
return this.triggers(cronEach(seconds, 'debounce'));
}
interval(seconds) {
return this.triggers(cronEach(seconds, 'interval'));
}
}
const tool = {
for(...names) {
//
const m = new Manager({
}, ...names);
m
.options({
'60': actions.Transformation.transform('MAP', 'automation.map', '1HOFF'),
'720': actions.Transformation.transform('MAP', 'automation.map', '12HOFF'),
'1440': actions.Transformation.transform('MAP', 'automation.map', '24OFF')
}, '720');
return m;
}
}
module.exports = new Proxy(tool, {
get: function (target, prop) {
return target[prop] || target.for(prop);
}
});