UNPKG

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
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); } });