OpenHausIO / backend

HTTP API for the OpenHaus SmartHome/IoT solution
https://docs.open-haus.io
6 stars 2 forks source link

Change scenes schema #505

Open mStirner opened 1 month ago

mStirner commented 1 month ago

Add the following properties:


1) Triggers are defined indpendelty. But with "conditions" you could create a appeal, that the trigger does not fire or is ignored. E.g. A LUX Sensor trigger a light scenes based on the light outside. But only between 17:00 & 20:00. This would prevent that the scenes fires in the morning, when its cloudy and the values jumps/jitter.

2) Currently the makro "command" does execute each command regardless if the previous command is done. (resolve() outside the commad handler callback): https://github.com/OpenHausIO/backend/blob/dbf499ec68eb4ab7d4204ab21efea51133fe824c/components/scenes/makro-types.js#L40 This can be a problem when one command dependet on the other command successful execution. But its not needed to wait for the previous command when, diffrent lights are turned on. Why wait for a light to turn on to turn another one on? So there should be a option where you specify if it should execute them "simultaneous" or in a synchronous way.

mStirner commented 1 month ago

Draft (Condition)

Code __class.condition.js__ ```js const Joi = require("joi"); const mongodb = require("mongodb"); module.exports = class Condition { constructor(obj) { Object.assign(this, obj); this._id = String(obj._id); } check() { if (this.type === "timerange") { let { startTime, endTime } = this.operation; return Condition.isWithinTimeWindow(`${startTime}-${endTime}`); } else if (this.type === "daterange") { let { startDate, endDate } = this.operation; return Condition.isWithinTimeWindow(`${startDate}-${endDate}`); } else { // not supported return false; } } static schema() { return Joi.object({ _id: Joi.string().pattern(/^[0-9a-fA-F]{24}$/).default(() => { return String(new mongodb.ObjectId()); }), type: Joi.string().valid("timerange", "daterange").required(), enabled: Joi.boolean().default(true), operation: Joi.object().when("type", { switch: [{ is: "timerange", then: Joi.object({ startTime: Joi.string().required(), endTime: Joi.string().required() }) }, { is: "daterange", then: Joi.object({ startDate: Joi.string().required(), endDate: Joi.string().required() }) }], otherwise: Joi.forbidden() }) }); } /** * @function validate * Validate schema object * * @static * * @param {Object} obj Input data that matches the schema * * @returns {Object} Joi validation object * * @link https://joi.dev/api/?v=17.6.0#anyvalidatevalue-options */ static validate(obj) { return Condition.schema().validate(obj); } static isWithinTimeWindow(input) { const now = new Date(); // Prüfen, ob der Input ein Zeitbereich oder ein Datumsbereich ist if (input.includes("-")) { // Whitespace entfernen und den Input in Start und Stop aufteilen const [start, stop] = input.split("-").map(part => part.trim()); // Zeitfenster im Format "HH:MM-HH:MM" if (start.includes(":") && stop.includes(":")) { const [startHour, startMinute] = start.split(":").map(Number); const [endHour, endMinute] = stop.split(":").map(Number); return Condition.checkTimeWindow(startHour, startMinute, endHour, endMinute, now); } // Datumsbereich im Format "YYYY.MM.DD - YYYY.MM.DD" else if (start.match(/^\d{4}\.\d{2}\.\d{2}$/) && stop.match(/^\d{4}\.\d{2}\.\d{2}$/)) { const startDate = new Date(start.replace(/\./g, "-")); // Punkte durch Bindestriche ersetzen const endDate = new Date(stop.replace(/\./g, "-")); // Punkte durch Bindestriche ersetzen return now >= startDate && now <= endDate; } } return false; } static checkTimeWindow(startHour, startMinute, endHour, endMinute, currentDate) { // Aktuelle Stunde und Minute const currentHour = currentDate.getHours(); const currentMinute = currentDate.getMinutes(); // Erstelle die Start- und Endzeit im Format "HHMM" (z.B. 0830 für 8:30) const startTime = startHour * 100 + startMinute; const endTime = endHour * 100 + endMinute; const currentTime = currentHour * 100 + currentMinute; // Überprüfen, ob aktuelle Zeit im Zeitfenster liegt if (startTime <= endTime) { // Zeitfenster liegt innerhalb eines Tages (z.B. 08:00 bis 18:00) return currentTime >= startTime && currentTime <= endTime; } else { // Zeitfenster geht über Mitternacht (z.B. 22:00 bis 06:00) return currentTime >= startTime || currentTime <= endTime; } } }; ``` __class.scene.js__ ```js const Joi = require("joi"); const mongodb = require("mongodb"); const { setTimeout } = require("timers/promises"); const debounce = require("../../helper/debounce.js"); const Makro = require("./class.makro.js"); const Trigger = require("./class.trigger.js"); const Condition = require("./class.condition.js"); const Item = require("../../system/component/class.item.js"); module.exports = class Scene extends Item { constructor(obj) { super(obj); // removed for #356 //Object.assign(this, obj); //this._id = String(obj._id); this.makros = obj.makros.map((makro) => { return new Makro(makro); }); this.triggers = obj.triggers.map((data) => { let trigger = new Trigger(data); trigger.signal.on("fire", () => { this.trigger(); }); return trigger; }); Object.defineProperty(this, "states", { value: { running: false, aborted: false, finished: false, index: 0 }, enumerable: false, configurable: false, writable: true }); Object.defineProperty(this, "_ac", { value: null, enumerable: false, configurable: false, writable: true }); // like in state updated // see components/endpoints/class.state.js let updater = debounce((prop, value) => { let { update, logger } = Scene.scope; update(this._id, this, (err) => { if (err) { // feedback logger.warn(err, `Could not save timestamp ${prop}=${value}`); } else { // feedback logger.debug(`Updated timestamps in database: ${prop}=${value}`); } }); }, 100); // wrap timestamps in proxy set trap // update item in database when the timestamps // "started", "finished" or "aborted" set // this ensures that theay are not `null` after a restart this.timestamps = new Proxy(obj.timestamps, { set: (target, prop, value, receiver) => { let { logger } = Scene.scope; if (["started", "finished", "aborted"].includes(prop) && value !== target[prop]) { // feedback logger.debug(`Update timestamp: ${prop}=${value}`); // call debounced `.update()` updater(prop, value); } return Reflect.set(target, prop, value, receiver); } }); } static schema() { return Joi.object({ _id: Joi.string().pattern(/^[0-9a-fA-F]{24}$/).default(() => { return String(new mongodb.ObjectId()); }), name: Joi.string().required(), makros: Joi.array().items(Makro.schema()).default([]), triggers: Joi.array().items(Trigger.schema()).default([]), conditions: Joi.array().items(Condition.schema()).default([]), visible: Joi.boolean().default(true), icon: Joi.string().allow(null).default(null), timestamps: { started: Joi.number().allow(null).default(null), aborted: Joi.number().allow(null).default(null), finished: Joi.number().allow(null).default(null) } }); } static validate(data) { return Scene.schema().validate(data); } trigger() { // fix #507 // stop previous running scene if (this.states.running && this._ac) { this._ac.abort(); } this.timestamps.started = Date.now(); let ac = new AbortController(); this._ac = ac; // wrap this in a custom method // that returns the state? // `getStates()` or so... this.states.running = true; this.states.aborted = false; this.states.finished = false; this.states.index = 0; let execute = this.conditions.every(({ check }) => { return check() === true; }); if (!execute) { console.log("Condition not true, do nothing!"); this.states.running = false; return; } else { console.log("Condition true, execute scene"); } let init = this.makros.filter(({ // enabled is per default "true" // when a marko should be disabled // this has explicit to be set to false enabled = true }) => { // execute only enabled makros return enabled; }).map((makro) => { // bind scope to method return makro.execute.bind(makro); }).reduce((acc, cur, i) => { return (result) => { return acc(result, this._ac.signal).then(async (r) => { if (this.states.aborted) { return Promise.reject("Aborted!"); } else { // NOTE: Intended to be a workaround for #329 & #312 // But the general idea of this is not bad // TODO: Add abort signal await setTimeout(Number(process.env.SCENES_MAKRO_DELAY)); // represents the current index of makro // e.g. timer takes 90min to finish, // index = timer makro in `makros` array this.states.index = i; return cur(r, this._ac.signal); } }).catch((err) => { console.log("Catched", i, err); return Promise.reject(err); }); }; }); return init(true, this._ac).then((result) => { console.log("Makro stack done", result); this.timestamps.finished = Date.now(); this.states.finished = true; }).catch((err) => { console.log("Makro stack aborted", err); this.states.finished = false; }).finally(() => { console.log("Finaly"); this.states.running = false; }); } abort() { // fix #507 if (this.states.running && this._ac) { this._ac.abort(); } this.states.running = false; this.states.aborted = true; this.states.finished = false; this.timestamps.aborted = Date.now(); } }; ```