diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index 6a7d80dff3..3dafd4bfcd 100644 --- a/front/src/config/i18n/de.json +++ b/front/src/config/i18n/de.json @@ -2633,6 +2633,13 @@ "warning": { "houseWithoutCoordinate": "Dein Zuhause hat keine Koordinaten. Dieser Auslöser funktioniert nicht ohne Koordinaten. Bitte gehe zu Einstellungen -> Zuhause und klicke auf die Karte, um die Koordinaten des Zuhauses festzulegen." }, + "sunriseSunsetTrigger": { + "offsetLabel": "Wann", + "atExactTime": "Zur genauen Uhrzeit", + "before": "Vor", + "after": "Nach", + "minutes": "Minuten" + }, "userPresence": { "backAtHomeDescription": "Dies wird ausgelöst, wenn der ausgewählte Benutzer wieder im ausgewählten Zuhause ist.", "leftHomeDescription": "Dies wird ausgelöst, wenn der ausgewählte Benutzer das ausgewählte Zuhause verlässt.", diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index f2a45b7092..0cfbca9a97 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -2633,6 +2633,13 @@ "warning": { "houseWithoutCoordinate": "Your house has no coordinates. This trigger cannot work without this data. Please go to Settings / Houses and click on the map to set the house coordinates." }, + "sunriseSunsetTrigger": { + "offsetLabel": "When", + "atExactTime": "At exact time", + "before": "Before", + "after": "After", + "minutes": "minutes" + }, "userPresence": { "backAtHomeDescription": "This will trigger when the selected user is back at the selected home.", "leftHomeDescription": "This will trigger when the selected user is leaving the selected home.", diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index f6da30046e..ee4b07687d 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -2633,6 +2633,13 @@ "warning": { "houseWithoutCoordinate": "Votre maison n'a pas de coordonnées renseignées. Ce déclencheur ne peut fonctionner sans cette donnée. Veuillez-vous rendre dans Paramètres/Maisons et cliquer sur la carte pour définir les coordonnées de cette maison." }, + "sunriseSunsetTrigger": { + "offsetLabel": "Quand", + "atExactTime": "À l'heure exacte", + "before": "Avant", + "after": "Après", + "minutes": "minutes" + }, "userPresence": { "backAtHomeDescription": "Cette scène s'exécutera lorsque l'utilisateur sélectionné rentrera à la maison sélectionnée.", "leftHomeDescription": "Cette scène s'exécutera lorsque l'utilisateur sélectionné partira de la maison sélectionnée.", diff --git a/front/src/routes/scene/edit-scene/triggers/SunriseSunsetTrigger.jsx b/front/src/routes/scene/edit-scene/triggers/SunriseSunsetTrigger.jsx index 2a93e3da3c..cb8b2750a5 100644 --- a/front/src/routes/scene/edit-scene/triggers/SunriseSunsetTrigger.jsx +++ b/front/src/routes/scene/edit-scene/triggers/SunriseSunsetTrigger.jsx @@ -27,10 +27,36 @@ class SunriseSunsetTrigger extends Component { this.props.updateTriggerProperty(this.props.index, 'house', houseSelector); }; + onOffsetDirectionChange = e => { + const direction = e.target.value; + const currentMinutes = Math.min(parseInt(this.state.offsetMinutesInput, 10) || 30, 1440); + if (direction === 'exact') { + this.props.updateTriggerProperty(this.props.index, 'offset', 0); + } else if (direction === 'before') { + this.props.updateTriggerProperty(this.props.index, 'offset', -currentMinutes); + } else { + this.props.updateTriggerProperty(this.props.index, 'offset', currentMinutes); + } + }; + + onOffsetMinutesChange = e => { + const raw = e.target.value; + this.setState({ offsetMinutesInput: raw }); + const minutes = parseInt(raw, 10); + if (!minutes || minutes <= 0 || minutes > 1440) { + return; + } + const currentOffset = this.props.trigger.offset || 0; + const newOffset = currentOffset < 0 ? -minutes : minutes; + this.props.updateTriggerProperty(this.props.index, 'offset', newOffset); + }; + constructor(props) { super(props); + const initialMinutes = Math.abs(props.trigger.offset || 0); this.state = { - houses: [] + houses: [], + offsetMinutesInput: initialMinutes > 0 ? String(initialMinutes) : '30' }; } @@ -38,7 +64,9 @@ class SunriseSunsetTrigger extends Component { this.getHouses(); } - render({}, { houses }) { + render({}, { houses, offsetMinutesInput }) { + const offset = this.props.trigger.offset || 0; + const offsetDirection = offset === 0 ? 'exact' : offset > 0 ? 'after' : 'before'; return (
@@ -46,6 +74,43 @@ class SunriseSunsetTrigger extends Component {
+
+
+
+ + +
+
+
+ {offsetDirection !== 'exact' && ( +
+
+ +
+
+ +
+
+ )} ); } diff --git a/server/lib/scene/scene.addScene.js b/server/lib/scene/scene.addScene.js index 36af10f277..94b825419e 100644 --- a/server/lib/scene/scene.addScene.js +++ b/server/lib/scene/scene.addScene.js @@ -6,6 +6,22 @@ const { EVENTS } = require('../../utils/constants'); const MAX_VALUE_SET_INTERVAL = 2 ** 31 - 1; +/** + * @description Has sunrise or sunset trigger. + * @param {object} scene - Scene object. + * @returns {boolean} Return true if the scene has a sunrise or sunset trigger. + * @example + * hasSunriseSunsetTrigger({ + * selector: 'test' + * }); + */ +function hasSunriseSunsetTrigger(scene) { + if (!scene.triggers) { + return false; + } + return scene.triggers.some((trigger) => trigger.type === EVENTS.TIME.SUNRISE || trigger.type === EVENTS.TIME.SUNSET); +} + const nodeScheduleDaysOfWeek = { sunday: 0, monday: 1, @@ -19,16 +35,20 @@ const nodeScheduleDaysOfWeek = { /** * @description Add a scene to the scene manager. * @param {object} sceneRaw - Scene object from DB. + * @param {object} [options] - Options. + * @param {boolean} [options.skipDailyUpdate=false] - Skip dailyUpdate call (e.g. During init). * @returns {object} Return the scene. * @example * addScene({ * selector: 'test' * }); */ -function addScene(sceneRaw) { +async function addScene(sceneRaw, { skipDailyUpdate = false } = {}) { // deep clone the scene so that we don't modify the same object which will be returned to the client const scene = cloneDeep(sceneRaw); // first, if the scene actually exist, we cancel all triggers + const previousScene = this.scenes[scene.selector]; + const hadSunriseSunset = previousScene && hasSunriseSunsetTrigger(previousScene); this.cancelTriggers(scene.selector); // Foreach triggger, we schedule jobs for triggers that need to be scheduled // only if the scene is active @@ -123,9 +143,13 @@ function addScene(sceneRaw) { this.scenes[scene.selector] = scene; this.brain.addNamedEntity('scene', scene.selector, scene.name); + if (!skipDailyUpdate && (hasSunriseSunsetTrigger(scene) || hadSunriseSunset)) { + await this.dailyUpdate(); + } return scene; } module.exports = { addScene, + hasSunriseSunsetTrigger, }; diff --git a/server/lib/scene/scene.create.js b/server/lib/scene/scene.create.js index 90880be9a2..478252a00f 100644 --- a/server/lib/scene/scene.create.js +++ b/server/lib/scene/scene.create.js @@ -32,7 +32,7 @@ async function create(scene) { const plainScene = createdScene.get({ plain: true }); // add scene to live store - this.addScene(plainScene); + await this.addScene(plainScene); // return created scene return plainScene; } diff --git a/server/lib/scene/scene.dailyUpdate.js b/server/lib/scene/scene.dailyUpdate.js index 59b2027a8b..e803acfcce 100644 --- a/server/lib/scene/scene.dailyUpdate.js +++ b/server/lib/scene/scene.dailyUpdate.js @@ -31,58 +31,54 @@ async function dailyUpdate() { .tz(this.timezone) .toDate(); const times = this.sunCalc.getTimes(todayAt12InMyTimeZone, house.latitude, house.longitude); - // Sunrise time - const sunriseHour = dayjs(times.sunrise) - .tz(this.timezone) - .get('hour'); - const sunriseMinute = dayjs(times.sunrise) - .tz(this.timezone) - .get('minute'); - const sunriseTime = dayjs() - .tz(this.timezone) - .hour(sunriseHour) - .minute(sunriseMinute) - .toDate(); - // Sunset time - const sunsetHour = dayjs(times.sunset) - .tz(this.timezone) - .get('hour'); - const sunsetMinute = dayjs(times.sunset) - .tz(this.timezone) - .get('minute'); - const sunsetTime = dayjs() - .tz(this.timezone) - .hour(sunsetHour) - .minute(sunsetMinute) - .toDate(); - logger.info(`Sunrise today is at ${sunriseHour}:${sunriseMinute} today, in your timezone = ${this.timezone}`); - logger.info(`Sunset today is at ${sunsetHour}:${sunsetMinute} today, in your timezone = ${this.timezone}`); - const sunriseJob = this.scheduler.scheduleJob(sunriseTime, () => - this.event.emit(EVENTS.TRIGGERS.CHECK, { - type: EVENTS.TIME.SUNRISE, - house, - }), - ); - if (sunriseJob) { - logger.info(`Sunrise is scheduled, ${dayjs(sunriseTime).fromNow()}.`); - this.jobs.push(sunriseJob); - } else { - logger.info(`The sun rose this morning. Not scheduling for today.`); - } + // Sunrise and Sunset base times + const sunriseBase = dayjs(times.sunrise).tz(this.timezone); + const sunsetBase = dayjs(times.sunset).tz(this.timezone); + logger.info(`Sunrise today is at ${sunriseBase.format('HH:mm')}, in your timezone = ${this.timezone}`); + logger.info(`Sunset today is at ${sunsetBase.format('HH:mm')}, in your timezone = ${this.timezone}`); + + // Collect all distinct offsets for this house from active scene triggers + const sunriseOffsets = new Set([0]); + const sunsetOffsets = new Set([0]); + Object.values(this.scenes).forEach((scene) => { + if (!scene.active || !scene.triggers) { + return; + } + scene.triggers.forEach((trigger) => { + const offset = Number(trigger.offset) || 0; + if (!Number.isInteger(offset) || Math.abs(offset) > 24 * 60) { + return; + } + if (trigger.type === EVENTS.TIME.SUNRISE && trigger.house === house.selector) { + sunriseOffsets.add(offset); + } else if (trigger.type === EVENTS.TIME.SUNSET && trigger.house === house.selector) { + sunsetOffsets.add(offset); + } + }); + }); - const sunsetJob = this.scheduler.scheduleJob(sunsetTime, () => - this.event.emit(EVENTS.TRIGGERS.CHECK, { - type: EVENTS.TIME.SUNSET, - house, - }), - ); + // Schedule one job per distinct (house, type, offset) combination + const scheduleForOffsets = (offsets, baseTime, eventType, label) => { + offsets.forEach((offset) => { + const time = baseTime.add(offset, 'minute').toDate(); + const job = this.scheduler.scheduleJob(time, () => + this.event.emit(EVENTS.TRIGGERS.CHECK, { type: eventType, house, offset }), + ); + if (job) { + logger.info( + `${label} (offset ${offset}min) is scheduled at ${dayjs(time) + .tz(this.timezone) + .format('HH:mm')}, ${dayjs(time).fromNow()}.`, + ); + this.jobs.push(job); + } else { + logger.info(`${label} (offset ${offset}min): time is in the past, not scheduling for today.`); + } + }); + }; - if (sunsetJob) { - logger.info(`Sunset is scheduled, ${dayjs(sunsetTime).fromNow()}.`); - this.jobs.push(sunsetJob); - } else { - logger.info(`The sun has already set. Not scheduling for today.`); - } + scheduleForOffsets(sunriseOffsets, sunriseBase, EVENTS.TIME.SUNRISE, 'Sunrise'); + scheduleForOffsets(sunsetOffsets, sunsetBase, EVENTS.TIME.SUNSET, 'Sunset'); } }); } diff --git a/server/lib/scene/scene.destroy.js b/server/lib/scene/scene.destroy.js index cbdbbf97ce..70d5098266 100644 --- a/server/lib/scene/scene.destroy.js +++ b/server/lib/scene/scene.destroy.js @@ -1,5 +1,6 @@ const db = require('../../models'); const { NotFoundError } = require('../../utils/coreErrors'); +const { hasSunriseSunsetTrigger } = require('./scene.addScene'); /** * @description Destroy a scene. @@ -35,10 +36,15 @@ async function destroy(selector) { }); await existingScene.destroy(); + // check if scene had sunrise/sunset triggers before deleting from RAM + const hadSunriseSunset = this.scenes[selector] && hasSunriseSunsetTrigger(this.scenes[selector]); // we cancel triggers linked to the scene this.cancelTriggers(selector); // then we delete the scene in RAM delete this.scenes[selector]; + if (hadSunriseSunset) { + await this.dailyUpdate(); + } } module.exports = { diff --git a/server/lib/scene/scene.init.js b/server/lib/scene/scene.init.js index cf5e280421..a4ad26495e 100644 --- a/server/lib/scene/scene.init.js +++ b/server/lib/scene/scene.init.js @@ -21,7 +21,7 @@ async function init() { const plainScene = scene.get({ plain: true }); logger.debug(`Loading scene ${plainScene.name}`); try { - this.addScene(plainScene); + this.addScene(plainScene, { skipDailyUpdate: true }); this.brain.addNamedEntity('scene', plainScene.selector, plainScene.name); logger.debug(`Scene loaded with success`); } catch (e) { diff --git a/server/lib/scene/scene.triggers.js b/server/lib/scene/scene.triggers.js index feb1ea0aa3..15a81ad77c 100644 --- a/server/lib/scene/scene.triggers.js +++ b/server/lib/scene/scene.triggers.js @@ -4,6 +4,9 @@ const logger = require('../../utils/logger'); const { EVENTS } = require('../../utils/constants'); const { compare } = require('../../utils/compare'); +const matchSunEvent = (self, sceneSelector, event, trigger) => + event.house.selector === trigger.house && (event.offset || 0) === (trigger.offset || 0); + const triggersFunc = { [EVENTS.DEVICE.NEW_STATE]: (self, sceneSelector, event, trigger) => { // we check that we are talking about the same device feature @@ -81,8 +84,8 @@ const triggersFunc = { return false; }, [EVENTS.TIME.CHANGED]: (self, sceneSelector, event, trigger) => event.key === trigger.key, - [EVENTS.TIME.SUNRISE]: (self, sceneSelector, event, trigger) => event.house.selector === trigger.house, - [EVENTS.TIME.SUNSET]: (self, sceneSelector, event, trigger) => event.house.selector === trigger.house, + [EVENTS.TIME.SUNRISE]: matchSunEvent, + [EVENTS.TIME.SUNSET]: matchSunEvent, [EVENTS.USER_PRESENCE.BACK_HOME]: (self, sceneSelector, event, trigger) => event.house === trigger.house && event.user === trigger.user, [EVENTS.USER_PRESENCE.LEFT_HOME]: (self, sceneSelector, event, trigger) => diff --git a/server/lib/scene/scene.update.js b/server/lib/scene/scene.update.js index 7b64f72fff..c27eaf727d 100644 --- a/server/lib/scene/scene.update.js +++ b/server/lib/scene/scene.update.js @@ -54,7 +54,7 @@ async function update(selector, scene) { this.brain.removeNamedEntity('scene', plainScene.selector, oldName); } // add scene to live store - this.addScene(plainScene); + await this.addScene(plainScene); // return updated scene return plainScene; } diff --git a/server/models/scene.js b/server/models/scene.js index 2626fb951c..671444457c 100644 --- a/server/models/scene.js +++ b/server/models/scene.js @@ -116,6 +116,10 @@ const triggersSchema = Joi.array().items( threshold_only: Joi.boolean(), topic: Joi.string(), message: Joi.string().allow(''), + offset: Joi.number() + .integer() + .min(-1440) + .max(1440), }), ); diff --git a/server/test/lib/scene/scene.addScene.test.js b/server/test/lib/scene/scene.addScene.test.js index e5a5a57f7c..ddf2be51e3 100644 --- a/server/test/lib/scene/scene.addScene.test.js +++ b/server/test/lib/scene/scene.addScene.test.js @@ -46,7 +46,7 @@ describe('SceneManager.addScene', () => { it('should NOT add a scene with an invalid trigger', async () => { try { - sceneManager.addScene({ + await sceneManager.addScene({ name: 'a-test-scene', icon: 'bell', active: true, @@ -66,7 +66,7 @@ describe('SceneManager.addScene', () => { } }); it('should add a scene with a scheduled trigger, every-month', async () => { - const scene = sceneManager.addScene({ + const scene = await sceneManager.addScene({ name: 'a-test-scene', icon: 'bell', active: true, @@ -89,7 +89,7 @@ describe('SceneManager.addScene', () => { assert.calledOnceWithExactly(event.emit, EVENTS.TRIGGERS.CHECK, trigger); }); it('should add a scene with a scheduled trigger, every-week', async () => { - const scene = sceneManager.addScene({ + const scene = await sceneManager.addScene({ name: 'a-test-scene', icon: 'bell', active: true, @@ -106,7 +106,7 @@ describe('SceneManager.addScene', () => { expect(sceneManager.scenes[scene.selector].triggers[0]).to.have.property('nodeScheduleJob'); }); it('should add a scene with a scheduled trigger, every-day', async () => { - const scene = sceneManager.addScene({ + const scene = await sceneManager.addScene({ name: 'a-test-scene', icon: 'bell', active: true, @@ -125,7 +125,7 @@ describe('SceneManager.addScene', () => { const in30Minutes = new Date(new Date().getTime() + 30 * 60 * 1000); const date = in30Minutes.toISOString().slice(0, 10); const time = in30Minutes.toLocaleTimeString('en-US', { hour12: false }).slice(0, 5); - const scene = sceneManager.addScene({ + const scene = await sceneManager.addScene({ name: 'a-test-scene', icon: 'bell', active: true, @@ -147,7 +147,7 @@ describe('SceneManager.addScene', () => { longitude: 50, }); - const scene = sceneManager.addScene({ + const scene = await sceneManager.addScene({ name: 'a-test-scene', icon: 'bell', active: true, @@ -165,7 +165,7 @@ describe('SceneManager.addScene', () => { }); it('should throw an error, interval is too big', async () => { try { - sceneManager.addScene({ + await sceneManager.addScene({ name: 'a-test-scene', icon: 'bell', active: true, @@ -186,7 +186,7 @@ describe('SceneManager.addScene', () => { }); it('should return error, interval not supported', async () => { try { - sceneManager.addScene({ + await sceneManager.addScene({ name: 'a-test-scene', icon: 'bell', active: true, @@ -206,7 +206,8 @@ describe('SceneManager.addScene', () => { } }); it('should add a scene with a scheduled trigger, sunrise', async () => { - const scene = sceneManager.addScene({ + sceneManager.dailyUpdate = fake.resolves(null); + const scene = await sceneManager.addScene({ name: 'a-test-scene', icon: 'bell', active: true, @@ -219,9 +220,11 @@ describe('SceneManager.addScene', () => { actions: [], }); expect(sceneManager.scenes[scene.selector].triggers[0]).to.not.have.property('nodeScheduleJob'); + assert.calledOnce(sceneManager.dailyUpdate); }); it('should add a scene with a scheduled trigger, sunset', async () => { - const scene = sceneManager.addScene({ + sceneManager.dailyUpdate = fake.resolves(null); + const scene = await sceneManager.addScene({ name: 'a-test-scene', icon: 'bell', active: true, @@ -235,9 +238,80 @@ describe('SceneManager.addScene', () => { actions: [], }); expect(sceneManager.scenes[scene.selector].triggers[0]).to.not.have.property('nodeScheduleJob'); + assert.calledOnce(sceneManager.dailyUpdate); + }); + it('should NOT call dailyUpdate when adding a scene without sunrise/sunset triggers', async () => { + sceneManager.dailyUpdate = fake.resolves(null); + await sceneManager.addScene({ + name: 'a-test-scene', + icon: 'bell', + active: true, + triggers: [ + { + type: EVENTS.TIME.CHANGED, + scheduler_type: 'every-day', + time: '12:00', + }, + ], + actions: [], + }); + assert.notCalled(sceneManager.dailyUpdate); + }); + it('should NOT call dailyUpdate when skipDailyUpdate option is true, even with a sunrise trigger', async () => { + sceneManager.dailyUpdate = fake.resolves(null); + await sceneManager.addScene( + { + name: 'a-test-scene', + icon: 'bell', + active: true, + triggers: [ + { + type: EVENTS.TIME.SUNRISE, + house: 'house', + }, + ], + actions: [], + }, + { skipDailyUpdate: true }, + ); + assert.notCalled(sceneManager.dailyUpdate); + }); + it('should call dailyUpdate when previous scene had sunrise trigger but new one does not', async () => { + sceneManager.dailyUpdate = fake.resolves(null); + const scene = await sceneManager.addScene({ + selector: 'my-scene', + name: 'a-test-scene', + icon: 'bell', + active: true, + triggers: [ + { + type: EVENTS.TIME.SUNRISE, + house: 'house', + }, + ], + actions: [], + }); + assert.calledOnce(sceneManager.dailyUpdate); + sceneManager.dailyUpdate.resetHistory(); + // Update the scene to remove the sunrise trigger + await sceneManager.addScene({ + selector: scene.selector, + name: 'a-test-scene', + icon: 'bell', + active: true, + triggers: [ + { + type: EVENTS.TIME.CHANGED, + scheduler_type: 'every-day', + time: '08:00', + }, + ], + actions: [], + }); + assert.calledOnce(sceneManager.dailyUpdate); }); it('should add a scene with a message received trigger', async () => { - const scene = sceneManager.addScene({ + const scene = await sceneManager.addScene({ name: 'a-test-scene', icon: 'bell', active: true, diff --git a/server/test/lib/scene/scene.checkTrigger.test.js b/server/test/lib/scene/scene.checkTrigger.test.js index 7b43695395..64838aa3c6 100644 --- a/server/test/lib/scene/scene.checkTrigger.test.js +++ b/server/test/lib/scene/scene.checkTrigger.test.js @@ -58,7 +58,7 @@ describe('scene.checkTrigger', () => { }); it('should execute scene', async () => { - const addedScene = sceneManager.addScene({ + const addedScene = await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -93,80 +93,8 @@ describe('scene.checkTrigger', () => { }); }); }); - it('should execute scene', async () => { - const addedScene = sceneManager.addScene({ - selector: 'my-scene', - active: true, - actions: [ - [ - { - type: ACTIONS.LIGHT.TURN_ON, - devices: ['light-1'], - }, - ], - ], - triggers: [ - { - type: EVENTS.TIME.SUNRISE, - house: 'house-1', - }, - ], - }); - sceneManager.checkTrigger({ - type: EVENTS.TIME.SUNRISE, - house: { - selector: addedScene.triggers[0].house, - }, - }); - return new Promise((resolve, reject) => { - sceneManager.queue.start(() => { - try { - assert.calledOnce(device.setValue); - resolve(); - } catch (e) { - reject(e); - } - }); - }); - }); - it('should execute scene', async () => { - sceneManager.addScene({ - selector: 'my-scene', - active: true, - actions: [ - [ - { - type: ACTIONS.LIGHT.TURN_OFF, - devices: ['light-1'], - }, - ], - ], - triggers: [ - { - type: EVENTS.TIME.SUNSET, - house: 'house-1', - }, - ], - }); - sceneManager.checkTrigger({ - type: EVENTS.TIME.SUNSET, - house: { - selector: 'house-1', - }, - }); - return new Promise((resolve, reject) => { - sceneManager.queue.start(() => { - try { - assert.calledOnce(device.setValue); - resolve(); - } catch (e) { - reject(e); - } - }); - }); - }); it('should execute scene with empty house trigger', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -201,7 +129,7 @@ describe('scene.checkTrigger', () => { }); it('should execute scene with no longer empty house trigger', async () => { - const addedScene = sceneManager.addScene({ + const addedScene = await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -236,7 +164,7 @@ describe('scene.checkTrigger', () => { }); it('should execute scene with user back home trigger', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -273,7 +201,7 @@ describe('scene.checkTrigger', () => { }); it('should execute scene with user left home trigger', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -310,7 +238,7 @@ describe('scene.checkTrigger', () => { }); it('should execute scene with user entered area trigger', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -347,7 +275,7 @@ describe('scene.checkTrigger', () => { }); it('should execute scene with user left area trigger', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -384,7 +312,7 @@ describe('scene.checkTrigger', () => { }); it('should execute scene with system start trigger', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -416,7 +344,7 @@ describe('scene.checkTrigger', () => { }); }); it('should not execute scene, event not matching', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -462,7 +390,7 @@ describe('scene.checkTrigger', () => { }).to.throw(Error, 'Trigger type "one-unknown-event" has no checker function.'); }); it('should execute scene, event & key matching', async () => { - const addedScene = sceneManager.addScene({ + const addedScene = await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -501,7 +429,7 @@ describe('scene.checkTrigger', () => { }); }); it('should not execute scene, key not matching', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ diff --git a/server/test/lib/scene/scene.dailyUpdate.test.js b/server/test/lib/scene/scene.dailyUpdate.test.js index bbee507e8b..a310d1efb6 100644 --- a/server/test/lib/scene/scene.dailyUpdate.test.js +++ b/server/test/lib/scene/scene.dailyUpdate.test.js @@ -3,6 +3,7 @@ const sinon = require('sinon'); const proxyquire = require('proxyquire').noCallThru(); const { fake, assert } = sinon; +const { EVENTS } = require('../../../utils/constants'); const SceneManager = proxyquire('../../../lib/scene', { suncalc: { @@ -38,6 +39,7 @@ describe('SceneManager.dailyUpdate', () => { beforeEach(async () => { house.get = fake.resolves([ { + selector: 'house-1', latitude: 12, longitude: 13, }, @@ -114,4 +116,156 @@ describe('SceneManager.dailyUpdate', () => { await sceneManager.dailyUpdate(); expect(sceneManager.jobs).to.have.lengthOf(0); }); + + it('should schedule extra job for sunrise when a scene has offset=30', async () => { + brain.addNamedEntity = fake.returns(null); + await sceneManager.addScene({ + selector: 'scene-offset', + active: true, + actions: [], + triggers: [ + { + type: EVENTS.TIME.SUNRISE, + house: 'house-1', + offset: 30, + }, + ], + }); + // addScene auto-triggers dailyUpdate(); flush microtask to let it complete + await Promise.resolve(); + // offset=0 sunrise + offset=30 sunrise + offset=0 sunset = 3 jobs + expect(sceneManager.jobs).to.have.lengthOf(3); + + // Trigger all jobs and verify events are emitted with correct offsets + const emittedOffsets = []; + event.emit = (eventName, payload) => { + emittedOffsets.push(payload.offset); + }; + sceneManager.jobs.forEach((job) => { + job.callback(); + }); + expect(emittedOffsets).to.include(0); + expect(emittedOffsets).to.include(30); + }); + + it('should schedule extra job for sunset when a scene has negative offset=-15', async () => { + brain.addNamedEntity = fake.returns(null); + await sceneManager.addScene({ + selector: 'scene-offset-neg', + active: true, + actions: [], + triggers: [ + { + type: EVENTS.TIME.SUNSET, + house: 'house-1', + offset: -15, + }, + ], + }); + // addScene auto-triggers dailyUpdate(); flush microtask to let it complete + await Promise.resolve(); + // offset=0 sunrise + offset=0 sunset + offset=-15 sunset = 3 jobs + expect(sceneManager.jobs).to.have.lengthOf(3); + + const emittedOffsets = []; + event.emit = (eventName, payload) => { + emittedOffsets.push(payload.offset); + }; + sceneManager.jobs.forEach((job) => { + job.callback(); + }); + expect(emittedOffsets).to.include(0); + expect(emittedOffsets).to.include(-15); + }); + + it('should not add extra jobs for an inactive scene with a sunrise trigger', async () => { + brain.addNamedEntity = fake.returns(null); + await sceneManager.addScene({ + selector: 'scene-inactive', + active: false, + actions: [], + triggers: [{ type: EVENTS.TIME.SUNRISE, house: 'house-1', offset: 30 }], + }); + // addScene auto-triggers dailyUpdate(); flush microtask to let it complete + await Promise.resolve(); + // Inactive scene offsets must be ignored: only offset=0 sunrise + offset=0 sunset = 2 jobs + expect(sceneManager.jobs).to.have.lengthOf(2); + }); + + it('should not add extra jobs for a scene with no triggers', async () => { + brain.addNamedEntity = fake.returns(null); + await sceneManager.addScene({ + selector: 'scene-no-triggers', + active: true, + actions: [], + triggers: null, + }); + await sceneManager.dailyUpdate(); + // Scene without triggers must be ignored: only offset=0 sunrise + offset=0 sunset = 2 jobs + expect(sceneManager.jobs).to.have.lengthOf(2); + }); + + it('should ignore a non-numeric offset (string)', async () => { + brain.addNamedEntity = fake.returns(null); + await sceneManager.addScene({ + selector: 'scene-bad-offset', + active: true, + actions: [], + triggers: [{ type: EVENTS.TIME.SUNRISE, house: 'house-1', offset: 'abc' }], + }); + // addScene auto-triggers dailyUpdate(); flush microtask to let it complete + await Promise.resolve(); + // invalid offset must be ignored: only offset=0 sunrise + offset=0 sunset = 2 jobs + expect(sceneManager.jobs).to.have.lengthOf(2); + }); + + it('should ignore an offset exceeding 24h (offset > 1440)', async () => { + brain.addNamedEntity = fake.returns(null); + await sceneManager.addScene({ + selector: 'scene-huge-offset', + active: true, + actions: [], + triggers: [{ type: EVENTS.TIME.SUNRISE, house: 'house-1', offset: 1500 }], + }); + // addScene auto-triggers dailyUpdate(); flush microtask to let it complete + await Promise.resolve(); + // out-of-day offset must be ignored: only offset=0 sunrise + offset=0 sunset = 2 jobs + expect(sceneManager.jobs).to.have.lengthOf(2); + }); + + it('should ignore a large negative offset exceeding 24h (offset < -1440)', async () => { + brain.addNamedEntity = fake.returns(null); + await sceneManager.addScene({ + selector: 'scene-huge-neg-offset', + active: true, + actions: [], + triggers: [{ type: EVENTS.TIME.SUNSET, house: 'house-1', offset: -1500 }], + }); + // addScene auto-triggers dailyUpdate(); flush microtask to let it complete + await Promise.resolve(); + // out-of-day offset must be ignored: only offset=0 sunrise + offset=0 sunset = 2 jobs + expect(sceneManager.jobs).to.have.lengthOf(2); + }); + + it('should deduplicate offsets when multiple scenes share the same offset', async () => { + brain.addNamedEntity = fake.returns(null); + await sceneManager.addScene({ + selector: 'scene-a', + active: true, + actions: [], + triggers: [{ type: EVENTS.TIME.SUNRISE, house: 'house-1', offset: 30 }], + }); + // flush first addScene auto-triggered dailyUpdate + await Promise.resolve(); + await sceneManager.addScene({ + selector: 'scene-b', + active: true, + actions: [], + triggers: [{ type: EVENTS.TIME.SUNRISE, house: 'house-1', offset: 30 }], + }); + // addScene auto-triggers dailyUpdate(); flush microtask to let it complete + await Promise.resolve(); + // offset=0 sunrise + offset=30 sunrise (deduplicated) + offset=0 sunset = 3 jobs + expect(sceneManager.jobs).to.have.lengthOf(3); + }); }); diff --git a/server/test/lib/scene/scene.destroy.test.js b/server/test/lib/scene/scene.destroy.test.js index 2c1042948f..4523d9727a 100644 --- a/server/test/lib/scene/scene.destroy.test.js +++ b/server/test/lib/scene/scene.destroy.test.js @@ -2,6 +2,7 @@ const { assert, expect } = require('chai'); const { fake, assert: assertSinon } = require('sinon'); const EventEmitter = require('events'); const SceneManager = require('../../../lib/scene'); +const { EVENTS } = require('../../../utils/constants'); const event = new EventEmitter(); @@ -26,4 +27,27 @@ describe('scene.destroy', () => { const promise = sceneManager.destroy('not-found-scene'); return assert.isRejected(promise); }); + it('should call dailyUpdate when destroying a scene with sunrise trigger', async () => { + sceneManager.dailyUpdate = fake.resolves(null); + sceneManager.scenes['test-scene'] = { + name: 'Test Scene', + triggers: [ + { + type: EVENTS.TIME.SUNRISE, + house: 'house', + }, + ], + }; + await sceneManager.destroy('test-scene'); + assertSinon.calledOnce(sceneManager.dailyUpdate); + }); + it('should NOT call dailyUpdate when destroying a scene without sunrise/sunset triggers', async () => { + sceneManager.dailyUpdate = fake.resolves(null); + sceneManager.scenes['test-scene'] = { + name: 'Test Scene', + triggers: [], + }; + await sceneManager.destroy('test-scene'); + assertSinon.notCalled(sceneManager.dailyUpdate); + }); }); diff --git a/server/test/lib/scene/scene.execute.test.js b/server/test/lib/scene/scene.execute.test.js index 6bdd1ca4ac..bbe99d6f98 100644 --- a/server/test/lib/scene/scene.execute.test.js +++ b/server/test/lib/scene/scene.execute.test.js @@ -39,7 +39,7 @@ describe('scene.execute', () => { ], ], }; - sceneManager.addScene(scene); + await sceneManager.addScene(scene); await sceneManager.execute('my-scene'); return new Promise((resolve, reject) => { sceneManager.queue.start(() => { @@ -82,7 +82,7 @@ describe('scene.execute', () => { ], ], }; - sceneManager.addScene(scene); + await sceneManager.addScene(scene); const scope = {}; await sceneManager.execute('my-scene', scope); return new Promise((resolve, reject) => { @@ -116,7 +116,7 @@ describe('scene.execute', () => { ], ], }; - sceneManager.addScene(scene); + await sceneManager.addScene(scene); const scope = {}; await sceneManager.execute('my-scene', scope); return new Promise((resolve, reject) => { @@ -155,8 +155,8 @@ describe('scene.execute', () => { ], ], }; - sceneManager.addScene(scene); - sceneManager.addScene(secondScene); + await sceneManager.addScene(scene); + await sceneManager.addScene(secondScene); await sceneManager.execute('my-scene', scope); return new Promise((resolve, reject) => { sceneManager.queue.start(() => { @@ -203,8 +203,8 @@ describe('scene.execute', () => { ], ], }; - sceneManager.addScene(scene); - sceneManager.addScene(secondScene); + await sceneManager.addScene(scene); + await sceneManager.addScene(secondScene); await sceneManager.execute('my-scene', scope); return new Promise((resolve, reject) => { sceneManager.queue.start(() => { diff --git a/server/test/lib/scene/scene.init.test.js b/server/test/lib/scene/scene.init.test.js index 74d69a20e4..919f7b9832 100644 --- a/server/test/lib/scene/scene.init.test.js +++ b/server/test/lib/scene/scene.init.test.js @@ -59,6 +59,23 @@ describe('scene.init', () => { scheduler.scheduleJob.getCall(3).callback(); assert.calledOnceWithExactly(event.emit, 'calendar.check-if-event-is-coming'); }); + it('should call dailyUpdate only once during init, even with multiple sunrise/sunset scenes', async () => { + await db.Scene.create({ + name: 'sunrise-scene-1', + icon: 'activity', + triggers: [{ type: 'time.sunrise', house: 'my-house' }], + actions: [[]], + }); + await db.Scene.create({ + name: 'sunrise-scene-2', + icon: 'activity', + triggers: [{ type: 'time.sunset', house: 'my-house' }], + actions: [[]], + }); + sceneManager.dailyUpdate = fake.resolves(null); + await sceneManager.init(); + assert.calledOnce(sceneManager.dailyUpdate); + }); it('should init scene with failure but not crash', async () => { await db.Scene.create({ name: 'broken-scene', diff --git a/server/test/lib/scene/triggers/scene.trigger.alarmMode.test.js b/server/test/lib/scene/triggers/scene.trigger.alarmMode.test.js index e8db1371e4..8bc684cd8b 100644 --- a/server/test/lib/scene/triggers/scene.trigger.alarmMode.test.js +++ b/server/test/lib/scene/triggers/scene.trigger.alarmMode.test.js @@ -46,7 +46,7 @@ describe('Scene.triggers.alarmMode', () => { }); it('should execute scene with alarm.arm trigger', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -80,7 +80,7 @@ describe('Scene.triggers.alarmMode', () => { }); }); it('should execute scene with alarm.arming trigger', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -114,7 +114,7 @@ describe('Scene.triggers.alarmMode', () => { }); }); it('should execute scene with alarm.disarm trigger', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -148,7 +148,7 @@ describe('Scene.triggers.alarmMode', () => { }); }); it('should execute scene with alarm.partial-arm trigger', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -182,7 +182,7 @@ describe('Scene.triggers.alarmMode', () => { }); }); it('should execute scene with alarm.panic trigger', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -216,7 +216,7 @@ describe('Scene.triggers.alarmMode', () => { }); }); it('should execute scene with alarm.too-many-codes-tests trigger', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -250,7 +250,7 @@ describe('Scene.triggers.alarmMode', () => { }); }); it('should not execute scene (house not matching)', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ diff --git a/server/test/lib/scene/triggers/scene.trigger.deviceNewState.test.js b/server/test/lib/scene/triggers/scene.trigger.deviceNewState.test.js index ee909c0fdf..67747ffde3 100644 --- a/server/test/lib/scene/triggers/scene.trigger.deviceNewState.test.js +++ b/server/test/lib/scene/triggers/scene.trigger.deviceNewState.test.js @@ -59,7 +59,7 @@ describe('scene.triggers.deviceNewState', () => { }); it('should execute scene', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -96,7 +96,7 @@ describe('scene.triggers.deviceNewState', () => { }); }); it('should not execute scene, scene not active', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: false, actions: [ @@ -133,7 +133,7 @@ describe('scene.triggers.deviceNewState', () => { }); }); it('should not execute scene, condition not verified', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -170,7 +170,7 @@ describe('scene.triggers.deviceNewState', () => { }); }); it('should not execute scene, device feature is not the same', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -207,7 +207,7 @@ describe('scene.triggers.deviceNewState', () => { }); }); it('should not execute scene, threshold already passed', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -246,7 +246,7 @@ describe('scene.triggers.deviceNewState', () => { }); }); it('should execute scene, threshold passed for the first time', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -285,7 +285,7 @@ describe('scene.triggers.deviceNewState', () => { }); }); it('should start timer to check later for state and not follow current scene', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -330,7 +330,7 @@ describe('scene.triggers.deviceNewState', () => { }); }); it('should start timer to check now and condition should still be valid on second call', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -372,7 +372,7 @@ describe('scene.triggers.deviceNewState', () => { }); }); it('should start timer to check now and re-send new value still validating the condition', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -420,7 +420,7 @@ describe('scene.triggers.deviceNewState', () => { }); }); it('should start timer to check now and condition should not be valid on second call', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -468,7 +468,7 @@ describe('scene.triggers.deviceNewState', () => { }); }); it('should execute scene with string value equality (text device feature like Shelly Button)', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -506,7 +506,7 @@ describe('scene.triggers.deviceNewState', () => { }); }); it('should not execute scene with string value equality when value does not match', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -544,7 +544,7 @@ describe('scene.triggers.deviceNewState', () => { }); }); it('should execute scene with string value inequality', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -582,7 +582,7 @@ describe('scene.triggers.deviceNewState', () => { }); }); it('should not execute scene with string value inequality when value matches', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ diff --git a/server/test/lib/scene/triggers/scene.trigger.mqttReceived.test.js b/server/test/lib/scene/triggers/scene.trigger.mqttReceived.test.js index 300639a704..de991d1362 100644 --- a/server/test/lib/scene/triggers/scene.trigger.mqttReceived.test.js +++ b/server/test/lib/scene/triggers/scene.trigger.mqttReceived.test.js @@ -54,7 +54,7 @@ describe('Scene.triggers.mqttReceived', () => { }); it('should execute scene with message received trigger', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -91,7 +91,7 @@ describe('Scene.triggers.mqttReceived', () => { }); it('should execute scene with message received trigger with undefined message (match any)', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -128,7 +128,7 @@ describe('Scene.triggers.mqttReceived', () => { }); it('should execute scene with message received trigger whit message', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ diff --git a/server/test/lib/scene/triggers/scene.trigger.sunriseSunset.test.js b/server/test/lib/scene/triggers/scene.trigger.sunriseSunset.test.js new file mode 100644 index 0000000000..4d3e664bc4 --- /dev/null +++ b/server/test/lib/scene/triggers/scene.trigger.sunriseSunset.test.js @@ -0,0 +1,246 @@ +const sinon = require('sinon'); + +const { assert, fake } = sinon; + +const EventEmitter = require('events'); +const StateManager = require('../../../../lib/state'); +const SceneManager = require('../../../../lib/scene'); +const { ACTIONS, EVENTS } = require('../../../../utils/constants'); + +const event = new EventEmitter(); + +describe('Scene.triggers.sunriseSunset', () => { + let sceneManager; + + const device = { + setValue: fake.resolves(null), + }; + + const brain = {}; + + const service = { + getService: fake.returns({ + device: { + subscribe: fake.returns(null), + }, + }), + }; + + beforeEach(() => { + const house = { + get: fake.resolves([]), + }; + + const scheduler = { + scheduleJob: (date, callback) => { + return { + callback, + date, + cancel: () => {}, + }; + }, + }; + + brain.addNamedEntity = fake.returns(null); + brain.removeNamedEntity = fake.returns(null); + + const stateManager = new StateManager(); + + sceneManager = new SceneManager(stateManager, event, device, {}, {}, house, {}, {}, {}, scheduler, brain, service); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should execute scene with sunrise trigger', async () => { + const addedScene = await sceneManager.addScene({ + selector: 'my-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_ON, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.TIME.SUNRISE, + house: 'house-1', + }, + ], + }); + sceneManager.checkTrigger({ + type: EVENTS.TIME.SUNRISE, + house: { + selector: addedScene.triggers[0].house, + }, + }); + return new Promise((resolve, reject) => { + sceneManager.queue.start(() => { + try { + assert.calledOnce(device.setValue); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); + + it('should execute scene with sunset trigger', async () => { + await sceneManager.addScene({ + selector: 'my-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_OFF, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.TIME.SUNSET, + house: 'house-1', + }, + ], + }); + sceneManager.checkTrigger({ + type: EVENTS.TIME.SUNSET, + house: { + selector: 'house-1', + }, + }); + return new Promise((resolve, reject) => { + sceneManager.queue.start(() => { + try { + assert.calledOnce(device.setValue); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); + + it('should not execute scene, sunrise trigger with offset=30 when event has offset=0', async () => { + await sceneManager.addScene({ + selector: 'my-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_ON, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.TIME.SUNRISE, + house: 'house-1', + offset: 30, + }, + ], + }); + sceneManager.checkTrigger({ + type: EVENTS.TIME.SUNRISE, + house: { + selector: 'house-1', + }, + offset: 0, + }); + return new Promise((resolve, reject) => { + sceneManager.queue.start(() => { + try { + assert.notCalled(device.setValue); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); + + it('should execute scene, sunrise trigger with offset=30 when event has offset=30', async () => { + await sceneManager.addScene({ + selector: 'my-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_ON, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.TIME.SUNRISE, + house: 'house-1', + offset: 30, + }, + ], + }); + sceneManager.checkTrigger({ + type: EVENTS.TIME.SUNRISE, + house: { + selector: 'house-1', + }, + offset: 30, + }); + return new Promise((resolve, reject) => { + sceneManager.queue.start(() => { + try { + assert.calledOnce(device.setValue); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); + + it('should not execute scene, sunset trigger with offset=-15 when event has offset=0', async () => { + await sceneManager.addScene({ + selector: 'my-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_OFF, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.TIME.SUNSET, + house: 'house-1', + offset: -15, + }, + ], + }); + sceneManager.checkTrigger({ + type: EVENTS.TIME.SUNSET, + house: { + selector: 'house-1', + }, + offset: 0, + }); + return new Promise((resolve, reject) => { + sceneManager.queue.start(() => { + try { + assert.notCalled(device.setValue); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); +});