From 988f7726e3b6c40dc9d354fe03e7228f5f6f1a04 Mon Sep 17 00:00:00 2001 From: Cyril Beslay Date: Sat, 28 Feb 2026 12:31:58 +0100 Subject: [PATCH 1/9] feat(scene): Add dealy feature on sunset/sunrise triggers --- front/src/config/i18n/en.json | 7 ++ front/src/config/i18n/fr.json | 7 ++ .../triggers/SunriseSunsetTrigger.jsx | 60 +++++++++ server/lib/scene/scene.dailyUpdate.js | 107 ++++++++-------- server/lib/scene/scene.triggers.js | 6 +- .../test/lib/scene/scene.checkTrigger.test.js | 114 ++++++++++++++++++ .../test/lib/scene/scene.dailyUpdate.test.js | 80 ++++++++++++ 7 files changed, 330 insertions(+), 51 deletions(-) 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..ccd810e14b 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": "A 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..0f1276d508 100644 --- a/front/src/routes/scene/edit-scene/triggers/SunriseSunsetTrigger.jsx +++ b/front/src/routes/scene/edit-scene/triggers/SunriseSunsetTrigger.jsx @@ -27,6 +27,25 @@ class SunriseSunsetTrigger extends Component { this.props.updateTriggerProperty(this.props.index, 'house', houseSelector); }; + onOffsetDirectionChange = e => { + const direction = e.target.value; + const currentMinutes = Math.abs(this.props.trigger.offset || 0) || 30; + 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 minutes = parseInt(e.target.value, 10) || 0; + 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); this.state = { @@ -39,6 +58,9 @@ class SunriseSunsetTrigger extends Component { } render({}, { houses }) { + const offset = this.props.trigger.offset || 0; + const offsetDirection = offset === 0 ? 'exact' : offset > 0 ? 'after' : 'before'; + const offsetMinutes = Math.abs(offset); return (
@@ -46,6 +68,44 @@ class SunriseSunsetTrigger extends Component {
+
+
+
+ + +
+
+
+ {offsetDirection !== 'exact' && ( +
+
+
+ + + + +
+
+
+ )} ); } diff --git a/server/lib/scene/scene.dailyUpdate.js b/server/lib/scene/scene.dailyUpdate.js index 59b2027a8b..98f047bf1b 100644 --- a/server/lib/scene/scene.dailyUpdate.js +++ b/server/lib/scene/scene.dailyUpdate.js @@ -31,58 +31,67 @@ 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, - }), + // 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.get('hour')}:${sunriseBase.get('minute')} today, in your timezone = ${this.timezone}`, ); - 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.`); - } - - const sunsetJob = this.scheduler.scheduleJob(sunsetTime, () => - this.event.emit(EVENTS.TRIGGERS.CHECK, { - type: EVENTS.TIME.SUNSET, - house, - }), + logger.info( + `Sunset today is at ${sunsetBase.get('hour')}:${sunsetBase.get('minute')} today, in your timezone = ${this.timezone}`, ); - 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.`); - } + // 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 = trigger.offset || 0; + 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); + } + }); + }); + + // Schedule one job per distinct (house, type, offset) combination + sunriseOffsets.forEach((offset) => { + const sunriseTime = sunriseBase.add(offset, 'minute').toDate(); + const sunriseJob = this.scheduler.scheduleJob(sunriseTime, () => + this.event.emit(EVENTS.TRIGGERS.CHECK, { + type: EVENTS.TIME.SUNRISE, + house, + offset, + }), + ); + if (sunriseJob) { + logger.info(`Sunrise (offset ${offset}min) is scheduled, ${dayjs(sunriseTime).fromNow()}.`); + this.jobs.push(sunriseJob); + } else { + logger.info(`Sunrise (offset ${offset}min): time is in the past, not scheduling for today.`); + } + }); + + sunsetOffsets.forEach((offset) => { + const sunsetTime = sunsetBase.add(offset, 'minute').toDate(); + const sunsetJob = this.scheduler.scheduleJob(sunsetTime, () => + this.event.emit(EVENTS.TRIGGERS.CHECK, { + type: EVENTS.TIME.SUNSET, + house, + offset, + }), + ); + if (sunsetJob) { + logger.info(`Sunset (offset ${offset}min) is scheduled, ${dayjs(sunsetTime).fromNow()}.`); + this.jobs.push(sunsetJob); + } else { + logger.info(`Sunset (offset ${offset}min): time is in the past, not scheduling for today.`); + } + }); } }); } diff --git a/server/lib/scene/scene.triggers.js b/server/lib/scene/scene.triggers.js index feb1ea0aa3..f24810520d 100644 --- a/server/lib/scene/scene.triggers.js +++ b/server/lib/scene/scene.triggers.js @@ -81,8 +81,10 @@ 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]: (self, sceneSelector, event, trigger) => + event.house.selector === trigger.house && (event.offset || 0) === (trigger.offset || 0), + [EVENTS.TIME.SUNSET]: (self, sceneSelector, event, trigger) => + event.house.selector === trigger.house && (event.offset || 0) === (trigger.offset || 0), [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/test/lib/scene/scene.checkTrigger.test.js b/server/test/lib/scene/scene.checkTrigger.test.js index 7b43695395..98705dc793 100644 --- a/server/test/lib/scene/scene.checkTrigger.test.js +++ b/server/test/lib/scene/scene.checkTrigger.test.js @@ -461,6 +461,120 @@ describe('scene.checkTrigger', () => { }); }).to.throw(Error, 'Trigger type "one-unknown-event" has no checker function.'); }); + it('should not execute scene, sunrise trigger with offset=30 when event has offset=0', async () => { + 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 () => { + 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 () => { + 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); + } + }); + }); + }); it('should execute scene, event & key matching', async () => { const addedScene = sceneManager.addScene({ selector: 'my-scene', diff --git a/server/test/lib/scene/scene.dailyUpdate.test.js b/server/test/lib/scene/scene.dailyUpdate.test.js index bbee507e8b..1ac44cf2d1 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,82 @@ 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); + sceneManager.addScene({ + selector: 'scene-offset', + active: true, + actions: [], + triggers: [ + { + type: EVENTS.TIME.SUNRISE, + house: 'house-1', + offset: 30, + }, + ], + }); + await sceneManager.dailyUpdate(); + // 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); + sceneManager.addScene({ + selector: 'scene-offset-neg', + active: true, + actions: [], + triggers: [ + { + type: EVENTS.TIME.SUNSET, + house: 'house-1', + offset: -15, + }, + ], + }); + await sceneManager.dailyUpdate(); + // 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 deduplicate offsets when multiple scenes share the same offset', async () => { + brain.addNamedEntity = fake.returns(null); + sceneManager.addScene({ + selector: 'scene-a', + active: true, + actions: [], + triggers: [{ type: 'time.sunrise', house: 'house-1', offset: 30 }], + }); + sceneManager.addScene({ + selector: 'scene-b', + active: true, + actions: [], + triggers: [{ type: 'time.sunrise', house: 'house-1', offset: 30 }], + }); + await sceneManager.dailyUpdate(); + // offset=0 sunrise + offset=30 sunrise (deduplicated) + offset=0 sunset = 3 jobs + expect(sceneManager.jobs).to.have.lengthOf(3); + }); }); From 3c8f2614d69fa27d7cc54f7c9163e4fa7202acd1 Mon Sep 17 00:00:00 2001 From: Cyril Beslay Date: Sun, 1 Mar 2026 13:38:01 +0100 Subject: [PATCH 2/9] fix: Scene model and UI --- front/src/config/i18n/de.json | 7 +++ .../triggers/SunriseSunsetTrigger.jsx | 44 +++++++++++-------- server/models/scene.js | 1 + 3 files changed, 33 insertions(+), 19 deletions(-) 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/routes/scene/edit-scene/triggers/SunriseSunsetTrigger.jsx b/front/src/routes/scene/edit-scene/triggers/SunriseSunsetTrigger.jsx index 0f1276d508..a1971f3cee 100644 --- a/front/src/routes/scene/edit-scene/triggers/SunriseSunsetTrigger.jsx +++ b/front/src/routes/scene/edit-scene/triggers/SunriseSunsetTrigger.jsx @@ -29,7 +29,9 @@ class SunriseSunsetTrigger extends Component { onOffsetDirectionChange = e => { const direction = e.target.value; - const currentMinutes = Math.abs(this.props.trigger.offset || 0) || 30; + const currentMinutes = this.state.offsetMinutesInput + ? Math.abs(parseInt(this.state.offsetMinutesInput, 10)) || 30 + : 30; if (direction === 'exact') { this.props.updateTriggerProperty(this.props.index, 'offset', 0); } else if (direction === 'before') { @@ -40,7 +42,12 @@ class SunriseSunsetTrigger extends Component { }; onOffsetMinutesChange = e => { - const minutes = parseInt(e.target.value, 10) || 0; + const raw = e.target.value; + this.setState({ offsetMinutesInput: raw }); + const minutes = parseInt(raw, 10); + if (!minutes || minutes <= 0) { + return; + } const currentOffset = this.props.trigger.offset || 0; const newOffset = currentOffset < 0 ? -minutes : minutes; this.props.updateTriggerProperty(this.props.index, 'offset', newOffset); @@ -48,8 +55,10 @@ class SunriseSunsetTrigger extends Component { constructor(props) { super(props); + const initialMinutes = Math.abs(props.trigger.offset || 0); this.state = { - houses: [] + houses: [], + offsetMinutesInput: initialMinutes > 0 ? String(initialMinutes) : '30' }; } @@ -57,10 +66,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'; - const offsetMinutes = Math.abs(offset); return (
@@ -89,20 +97,18 @@ class SunriseSunsetTrigger extends Component {
{offsetDirection !== 'exact' && ( -
-
-
- - - - -
+
+
+ +
+
+
)} diff --git a/server/models/scene.js b/server/models/scene.js index 2626fb951c..89c78cdc75 100644 --- a/server/models/scene.js +++ b/server/models/scene.js @@ -116,6 +116,7 @@ const triggersSchema = Joi.array().items( threshold_only: Joi.boolean(), topic: Joi.string(), message: Joi.string().allow(''), + offset: Joi.number().integer(), }), ); From 14c7dea3c7f1003b957a0ccc7f7e983124af23b7 Mon Sep 17 00:00:00 2001 From: Cyril Beslay Date: Mon, 2 Mar 2026 12:04:57 +0100 Subject: [PATCH 3/9] fix: Fix some AI PR Feedbacks --- front/src/config/i18n/fr.json | 2 +- server/lib/scene/scene.dailyUpdate.js | 8 ++++++-- server/test/lib/scene/scene.dailyUpdate.test.js | 4 ++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index ccd810e14b..ee4b07687d 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -2635,7 +2635,7 @@ }, "sunriseSunsetTrigger": { "offsetLabel": "Quand", - "atExactTime": "A l'heure exacte", + "atExactTime": "À l'heure exacte", "before": "Avant", "after": "Après", "minutes": "minutes" diff --git a/server/lib/scene/scene.dailyUpdate.js b/server/lib/scene/scene.dailyUpdate.js index 98f047bf1b..d841bd5ec0 100644 --- a/server/lib/scene/scene.dailyUpdate.js +++ b/server/lib/scene/scene.dailyUpdate.js @@ -35,10 +35,14 @@ async function dailyUpdate() { const sunriseBase = dayjs(times.sunrise).tz(this.timezone); const sunsetBase = dayjs(times.sunset).tz(this.timezone); logger.info( - `Sunrise today is at ${sunriseBase.get('hour')}:${sunriseBase.get('minute')} today, in your timezone = ${this.timezone}`, + `Sunrise today is at ${sunriseBase.get('hour')}:${sunriseBase.get('minute')} today, in your timezone = ${ + this.timezone + }`, ); logger.info( - `Sunset today is at ${sunsetBase.get('hour')}:${sunsetBase.get('minute')} today, in your timezone = ${this.timezone}`, + `Sunset today is at ${sunsetBase.get('hour')}:${sunsetBase.get('minute')} today, in your timezone = ${ + this.timezone + }`, ); // Collect all distinct offsets for this house from active scene triggers diff --git a/server/test/lib/scene/scene.dailyUpdate.test.js b/server/test/lib/scene/scene.dailyUpdate.test.js index 1ac44cf2d1..700c2a819c 100644 --- a/server/test/lib/scene/scene.dailyUpdate.test.js +++ b/server/test/lib/scene/scene.dailyUpdate.test.js @@ -182,13 +182,13 @@ describe('SceneManager.dailyUpdate', () => { selector: 'scene-a', active: true, actions: [], - triggers: [{ type: 'time.sunrise', house: 'house-1', offset: 30 }], + triggers: [{ type: EVENTS.TIME.SUNRISE, house: 'house-1', offset: 30 }], }); sceneManager.addScene({ selector: 'scene-b', active: true, actions: [], - triggers: [{ type: 'time.sunrise', house: 'house-1', offset: 30 }], + triggers: [{ type: EVENTS.TIME.SUNRISE, house: 'house-1', offset: 30 }], }); await sceneManager.dailyUpdate(); // offset=0 sunrise + offset=30 sunrise (deduplicated) + offset=0 sunset = 3 jobs From 0154c9cd30e6df407f09ae076e84735902a9d500 Mon Sep 17 00:00:00 2001 From: Cyril Beslay Date: Mon, 2 Mar 2026 23:25:29 +0100 Subject: [PATCH 4/9] fix: Add missing coverage --- .../test/lib/scene/scene.dailyUpdate.test.js | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/server/test/lib/scene/scene.dailyUpdate.test.js b/server/test/lib/scene/scene.dailyUpdate.test.js index 700c2a819c..349d33bf16 100644 --- a/server/test/lib/scene/scene.dailyUpdate.test.js +++ b/server/test/lib/scene/scene.dailyUpdate.test.js @@ -176,6 +176,32 @@ describe('SceneManager.dailyUpdate', () => { 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); + sceneManager.addScene({ + selector: 'scene-inactive', + active: false, + actions: [], + triggers: [{ type: EVENTS.TIME.SUNRISE, house: 'house-1', offset: 30 }], + }); + await sceneManager.dailyUpdate(); + // 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); + 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 deduplicate offsets when multiple scenes share the same offset', async () => { brain.addNamedEntity = fake.returns(null); sceneManager.addScene({ From 2ab542a4d050cde18ef67fe477dd53ca917044d5 Mon Sep 17 00:00:00 2001 From: Cyril Beslay Date: Mon, 30 Mar 2026 21:33:50 +0200 Subject: [PATCH 5/9] fix(scene): Take in account PR feedbacks - Check offset range to avoid out of day scheduled job - Move tests from sunset/sunrise triggers into a dedicated file - Trigger dailyUpdate when scene containing sunrise/sunset triggers is modified --- .../triggers/SunriseSunsetTrigger.jsx | 4 +- server/lib/scene/scene.addScene.js | 22 ++ server/lib/scene/scene.dailyUpdate.js | 59 ++--- server/lib/scene/scene.destroy.js | 6 + server/lib/scene/scene.triggers.js | 9 +- server/test/lib/scene/scene.addScene.test.js | 55 ++++ .../test/lib/scene/scene.checkTrigger.test.js | 186 ------------- .../test/lib/scene/scene.dailyUpdate.test.js | 56 +++- server/test/lib/scene/scene.destroy.test.js | 24 ++ .../scene.trigger.sunriseSunset.test.js | 246 ++++++++++++++++++ 10 files changed, 433 insertions(+), 234 deletions(-) create mode 100644 server/test/lib/scene/triggers/scene.trigger.sunriseSunset.test.js diff --git a/front/src/routes/scene/edit-scene/triggers/SunriseSunsetTrigger.jsx b/front/src/routes/scene/edit-scene/triggers/SunriseSunsetTrigger.jsx index a1971f3cee..50aab97ea8 100644 --- a/front/src/routes/scene/edit-scene/triggers/SunriseSunsetTrigger.jsx +++ b/front/src/routes/scene/edit-scene/triggers/SunriseSunsetTrigger.jsx @@ -29,9 +29,7 @@ class SunriseSunsetTrigger extends Component { onOffsetDirectionChange = e => { const direction = e.target.value; - const currentMinutes = this.state.offsetMinutesInput - ? Math.abs(parseInt(this.state.offsetMinutesInput, 10)) || 30 - : 30; + const currentMinutes = parseInt(this.state.offsetMinutesInput, 10) || 30; if (direction === 'exact') { this.props.updateTriggerProperty(this.props.index, 'offset', 0); } else if (direction === 'before') { diff --git a/server/lib/scene/scene.addScene.js b/server/lib/scene/scene.addScene.js index 36af10f277..5a66db56d5 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, @@ -29,6 +45,8 @@ function addScene(sceneRaw) { // 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 +141,13 @@ function addScene(sceneRaw) { this.scenes[scene.selector] = scene; this.brain.addNamedEntity('scene', scene.selector, scene.name); + if (hasSunriseSunsetTrigger(scene) || hadSunriseSunset) { + this.dailyUpdate(); + } return scene; } module.exports = { addScene, + hasSunriseSunsetTrigger, }; diff --git a/server/lib/scene/scene.dailyUpdate.js b/server/lib/scene/scene.dailyUpdate.js index d841bd5ec0..987e2d6dcf 100644 --- a/server/lib/scene/scene.dailyUpdate.js +++ b/server/lib/scene/scene.dailyUpdate.js @@ -35,14 +35,12 @@ async function dailyUpdate() { const sunriseBase = dayjs(times.sunrise).tz(this.timezone); const sunsetBase = dayjs(times.sunset).tz(this.timezone); logger.info( - `Sunrise today is at ${sunriseBase.get('hour')}:${sunriseBase.get('minute')} today, in your timezone = ${ + `Sunrise today is at ${sunriseBase.get('hour')}:${sunriseBase.get('minute')}, in your timezone = ${ this.timezone }`, ); logger.info( - `Sunset today is at ${sunsetBase.get('hour')}:${sunsetBase.get('minute')} today, in your timezone = ${ - this.timezone - }`, + `Sunset today is at ${sunsetBase.get('hour')}:${sunsetBase.get('minute')}, in your timezone = ${this.timezone}`, ); // Collect all distinct offsets for this house from active scene triggers @@ -53,7 +51,10 @@ async function dailyUpdate() { return; } scene.triggers.forEach((trigger) => { - const offset = trigger.offset || 0; + 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) { @@ -63,39 +64,23 @@ async function dailyUpdate() { }); // Schedule one job per distinct (house, type, offset) combination - sunriseOffsets.forEach((offset) => { - const sunriseTime = sunriseBase.add(offset, 'minute').toDate(); - const sunriseJob = this.scheduler.scheduleJob(sunriseTime, () => - this.event.emit(EVENTS.TRIGGERS.CHECK, { - type: EVENTS.TIME.SUNRISE, - house, - offset, - }), - ); - if (sunriseJob) { - logger.info(`Sunrise (offset ${offset}min) is scheduled, ${dayjs(sunriseTime).fromNow()}.`); - this.jobs.push(sunriseJob); - } else { - logger.info(`Sunrise (offset ${offset}min): time is in the past, not scheduling for today.`); - } - }); + 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, ${dayjs(time).fromNow()}.`); + this.jobs.push(job); + } else { + logger.info(`${label} (offset ${offset}min): time is in the past, not scheduling for today.`); + } + }); + }; - sunsetOffsets.forEach((offset) => { - const sunsetTime = sunsetBase.add(offset, 'minute').toDate(); - const sunsetJob = this.scheduler.scheduleJob(sunsetTime, () => - this.event.emit(EVENTS.TRIGGERS.CHECK, { - type: EVENTS.TIME.SUNSET, - house, - offset, - }), - ); - if (sunsetJob) { - logger.info(`Sunset (offset ${offset}min) is scheduled, ${dayjs(sunsetTime).fromNow()}.`); - this.jobs.push(sunsetJob); - } else { - logger.info(`Sunset (offset ${offset}min): time is in the past, 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.triggers.js b/server/lib/scene/scene.triggers.js index f24810520d..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,10 +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 && (event.offset || 0) === (trigger.offset || 0), - [EVENTS.TIME.SUNSET]: (self, sceneSelector, event, trigger) => - event.house.selector === trigger.house && (event.offset || 0) === (trigger.offset || 0), + [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/test/lib/scene/scene.addScene.test.js b/server/test/lib/scene/scene.addScene.test.js index e5a5a57f7c..ff1b1d63d9 100644 --- a/server/test/lib/scene/scene.addScene.test.js +++ b/server/test/lib/scene/scene.addScene.test.js @@ -206,6 +206,7 @@ describe('SceneManager.addScene', () => { } }); it('should add a scene with a scheduled trigger, sunrise', async () => { + sceneManager.dailyUpdate = fake.resolves(null); const scene = sceneManager.addScene({ name: 'a-test-scene', icon: 'bell', @@ -219,8 +220,10 @@ 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 () => { + sceneManager.dailyUpdate = fake.resolves(null); const scene = sceneManager.addScene({ name: 'a-test-scene', icon: 'bell', @@ -235,6 +238,58 @@ 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); + 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 call dailyUpdate when previous scene had sunrise trigger but new one does not', async () => { + sceneManager.dailyUpdate = fake.resolves(null); + const scene = 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 + 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({ diff --git a/server/test/lib/scene/scene.checkTrigger.test.js b/server/test/lib/scene/scene.checkTrigger.test.js index 98705dc793..f0f2522f84 100644 --- a/server/test/lib/scene/scene.checkTrigger.test.js +++ b/server/test/lib/scene/scene.checkTrigger.test.js @@ -93,78 +93,6 @@ 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({ selector: 'my-scene', @@ -461,120 +389,6 @@ describe('scene.checkTrigger', () => { }); }).to.throw(Error, 'Trigger type "one-unknown-event" has no checker function.'); }); - it('should not execute scene, sunrise trigger with offset=30 when event has offset=0', async () => { - 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 () => { - 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 () => { - 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); - } - }); - }); - }); it('should execute scene, event & key matching', async () => { const addedScene = sceneManager.addScene({ selector: 'my-scene', diff --git a/server/test/lib/scene/scene.dailyUpdate.test.js b/server/test/lib/scene/scene.dailyUpdate.test.js index 349d33bf16..d53df5bd45 100644 --- a/server/test/lib/scene/scene.dailyUpdate.test.js +++ b/server/test/lib/scene/scene.dailyUpdate.test.js @@ -131,7 +131,8 @@ describe('SceneManager.dailyUpdate', () => { }, ], }); - await sceneManager.dailyUpdate(); + // 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); @@ -161,7 +162,8 @@ describe('SceneManager.dailyUpdate', () => { }, ], }); - await sceneManager.dailyUpdate(); + // 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); @@ -184,7 +186,8 @@ describe('SceneManager.dailyUpdate', () => { actions: [], triggers: [{ type: EVENTS.TIME.SUNRISE, house: 'house-1', offset: 30 }], }); - await sceneManager.dailyUpdate(); + // 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); }); @@ -202,6 +205,48 @@ describe('SceneManager.dailyUpdate', () => { expect(sceneManager.jobs).to.have.lengthOf(2); }); + it('should ignore a non-numeric offset (string)', async () => { + brain.addNamedEntity = fake.returns(null); + 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); + 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); + 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); sceneManager.addScene({ @@ -210,13 +255,16 @@ describe('SceneManager.dailyUpdate', () => { actions: [], triggers: [{ type: EVENTS.TIME.SUNRISE, house: 'house-1', offset: 30 }], }); + // flush first addScene auto-triggered dailyUpdate + await Promise.resolve(); sceneManager.addScene({ selector: 'scene-b', active: true, actions: [], triggers: [{ type: EVENTS.TIME.SUNRISE, house: 'house-1', offset: 30 }], }); - await sceneManager.dailyUpdate(); + // 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/triggers/scene.trigger.sunriseSunset.test.js b/server/test/lib/scene/triggers/scene.trigger.sunriseSunset.test.js new file mode 100644 index 0000000000..b7915271c6 --- /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 = 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 () => { + 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 () => { + 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 () => { + 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 () => { + 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); + } + }); + }); + }); +}); From 9c773c296199f7cb0afde2a912a158cbf4821f57 Mon Sep 17 00:00:00 2001 From: Cyril Beslay Date: Tue, 31 Mar 2026 20:53:58 +0200 Subject: [PATCH 6/9] fix: Optimize init to recompute schedules once --- server/lib/scene/scene.addScene.js | 6 ++++-- server/lib/scene/scene.init.js | 2 +- server/test/lib/scene/scene.addScene.test.js | 19 +++++++++++++++++++ server/test/lib/scene/scene.init.test.js | 17 +++++++++++++++++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/server/lib/scene/scene.addScene.js b/server/lib/scene/scene.addScene.js index 5a66db56d5..e9e721bc6d 100644 --- a/server/lib/scene/scene.addScene.js +++ b/server/lib/scene/scene.addScene.js @@ -35,13 +35,15 @@ 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) { +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 @@ -141,7 +143,7 @@ function addScene(sceneRaw) { this.scenes[scene.selector] = scene; this.brain.addNamedEntity('scene', scene.selector, scene.name); - if (hasSunriseSunsetTrigger(scene) || hadSunriseSunset) { + if (!skipDailyUpdate && (hasSunriseSunsetTrigger(scene) || hadSunriseSunset)) { this.dailyUpdate(); } return scene; 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/test/lib/scene/scene.addScene.test.js b/server/test/lib/scene/scene.addScene.test.js index ff1b1d63d9..eb0b61aae9 100644 --- a/server/test/lib/scene/scene.addScene.test.js +++ b/server/test/lib/scene/scene.addScene.test.js @@ -257,6 +257,25 @@ describe('SceneManager.addScene', () => { }); 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); + 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 = sceneManager.addScene({ 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', From b3055e2ebcc1eb2070fe8ede075876a6ad8b4eba Mon Sep 17 00:00:00 2001 From: Cyril Beslay Date: Sat, 4 Apr 2026 14:56:19 +0200 Subject: [PATCH 7/9] fix: Catch error and enforce offset --- .../scene/edit-scene/triggers/SunriseSunsetTrigger.jsx | 5 +++-- server/lib/scene/scene.addScene.js | 3 ++- server/models/scene.js | 5 ++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/front/src/routes/scene/edit-scene/triggers/SunriseSunsetTrigger.jsx b/front/src/routes/scene/edit-scene/triggers/SunriseSunsetTrigger.jsx index 50aab97ea8..cb8b2750a5 100644 --- a/front/src/routes/scene/edit-scene/triggers/SunriseSunsetTrigger.jsx +++ b/front/src/routes/scene/edit-scene/triggers/SunriseSunsetTrigger.jsx @@ -29,7 +29,7 @@ class SunriseSunsetTrigger extends Component { onOffsetDirectionChange = e => { const direction = e.target.value; - const currentMinutes = parseInt(this.state.offsetMinutesInput, 10) || 30; + 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') { @@ -43,7 +43,7 @@ class SunriseSunsetTrigger extends Component { const raw = e.target.value; this.setState({ offsetMinutesInput: raw }); const minutes = parseInt(raw, 10); - if (!minutes || minutes <= 0) { + if (!minutes || minutes <= 0 || minutes > 1440) { return; } const currentOffset = this.props.trigger.offset || 0; @@ -101,6 +101,7 @@ class SunriseSunsetTrigger extends Component { type="number" class="form-control" min="1" + max="1440" value={offsetMinutesInput} onInput={this.onOffsetMinutesChange} /> diff --git a/server/lib/scene/scene.addScene.js b/server/lib/scene/scene.addScene.js index e9e721bc6d..c85fa2a357 100644 --- a/server/lib/scene/scene.addScene.js +++ b/server/lib/scene/scene.addScene.js @@ -3,6 +3,7 @@ const uuid = require('uuid'); const { BadParameters } = require('../../utils/coreErrors'); const { EVENTS } = require('../../utils/constants'); +const logger = require('../../utils/logger'); const MAX_VALUE_SET_INTERVAL = 2 ** 31 - 1; @@ -144,7 +145,7 @@ function addScene(sceneRaw, { skipDailyUpdate = false } = {}) { this.scenes[scene.selector] = scene; this.brain.addNamedEntity('scene', scene.selector, scene.name); if (!skipDailyUpdate && (hasSunriseSunsetTrigger(scene) || hadSunriseSunset)) { - this.dailyUpdate(); + this.dailyUpdate().catch((e) => logger.error(e)); } return scene; } diff --git a/server/models/scene.js b/server/models/scene.js index 89c78cdc75..671444457c 100644 --- a/server/models/scene.js +++ b/server/models/scene.js @@ -116,7 +116,10 @@ const triggersSchema = Joi.array().items( threshold_only: Joi.boolean(), topic: Joi.string(), message: Joi.string().allow(''), - offset: Joi.number().integer(), + offset: Joi.number() + .integer() + .min(-1440) + .max(1440), }), ); From 88d35a8f779c68b195b80c320f0b4fe090cf55f9 Mon Sep 17 00:00:00 2001 From: Cyril Beslay Date: Sat, 4 Apr 2026 21:03:46 +0200 Subject: [PATCH 8/9] fix: Make addScene async --- server/lib/scene/scene.addScene.js | 5 ++-- server/lib/scene/scene.create.js | 2 +- server/lib/scene/scene.update.js | 2 +- server/test/lib/scene/scene.addScene.test.js | 30 +++++++++---------- .../test/lib/scene/scene.checkTrigger.test.js | 22 +++++++------- .../test/lib/scene/scene.dailyUpdate.test.js | 18 +++++------ server/test/lib/scene/scene.execute.test.js | 14 ++++----- .../triggers/scene.trigger.alarmMode.test.js | 14 ++++----- .../scene.trigger.deviceNewState.test.js | 28 ++++++++--------- .../scene.trigger.mqttReceived.test.js | 6 ++-- .../scene.trigger.sunriseSunset.test.js | 10 +++---- 11 files changed, 75 insertions(+), 76 deletions(-) diff --git a/server/lib/scene/scene.addScene.js b/server/lib/scene/scene.addScene.js index c85fa2a357..94b825419e 100644 --- a/server/lib/scene/scene.addScene.js +++ b/server/lib/scene/scene.addScene.js @@ -3,7 +3,6 @@ const uuid = require('uuid'); const { BadParameters } = require('../../utils/coreErrors'); const { EVENTS } = require('../../utils/constants'); -const logger = require('../../utils/logger'); const MAX_VALUE_SET_INTERVAL = 2 ** 31 - 1; @@ -44,7 +43,7 @@ const nodeScheduleDaysOfWeek = { * selector: 'test' * }); */ -function addScene(sceneRaw, { skipDailyUpdate = false } = {}) { +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 @@ -145,7 +144,7 @@ function addScene(sceneRaw, { skipDailyUpdate = false } = {}) { this.scenes[scene.selector] = scene; this.brain.addNamedEntity('scene', scene.selector, scene.name); if (!skipDailyUpdate && (hasSunriseSunsetTrigger(scene) || hadSunriseSunset)) { - this.dailyUpdate().catch((e) => logger.error(e)); + await this.dailyUpdate(); } return scene; } 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.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/test/lib/scene/scene.addScene.test.js b/server/test/lib/scene/scene.addScene.test.js index eb0b61aae9..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, @@ -207,7 +207,7 @@ describe('SceneManager.addScene', () => { }); it('should add a scene with a scheduled trigger, sunrise', async () => { sceneManager.dailyUpdate = fake.resolves(null); - const scene = sceneManager.addScene({ + const scene = await sceneManager.addScene({ name: 'a-test-scene', icon: 'bell', active: true, @@ -224,7 +224,7 @@ describe('SceneManager.addScene', () => { }); it('should add a scene with a scheduled trigger, sunset', async () => { sceneManager.dailyUpdate = fake.resolves(null); - const scene = sceneManager.addScene({ + const scene = await sceneManager.addScene({ name: 'a-test-scene', icon: 'bell', active: true, @@ -242,7 +242,7 @@ describe('SceneManager.addScene', () => { }); it('should NOT call dailyUpdate when adding a scene without sunrise/sunset triggers', async () => { sceneManager.dailyUpdate = fake.resolves(null); - sceneManager.addScene({ + await sceneManager.addScene({ name: 'a-test-scene', icon: 'bell', active: true, @@ -259,7 +259,7 @@ describe('SceneManager.addScene', () => { }); it('should NOT call dailyUpdate when skipDailyUpdate option is true, even with a sunrise trigger', async () => { sceneManager.dailyUpdate = fake.resolves(null); - sceneManager.addScene( + await sceneManager.addScene( { name: 'a-test-scene', icon: 'bell', @@ -278,7 +278,7 @@ describe('SceneManager.addScene', () => { }); it('should call dailyUpdate when previous scene had sunrise trigger but new one does not', async () => { sceneManager.dailyUpdate = fake.resolves(null); - const scene = sceneManager.addScene({ + const scene = await sceneManager.addScene({ selector: 'my-scene', name: 'a-test-scene', icon: 'bell', @@ -294,7 +294,7 @@ describe('SceneManager.addScene', () => { assert.calledOnce(sceneManager.dailyUpdate); sceneManager.dailyUpdate.resetHistory(); // Update the scene to remove the sunrise trigger - sceneManager.addScene({ + await sceneManager.addScene({ selector: scene.selector, name: 'a-test-scene', icon: 'bell', @@ -311,7 +311,7 @@ describe('SceneManager.addScene', () => { 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 f0f2522f84..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: [ @@ -94,7 +94,7 @@ describe('scene.checkTrigger', () => { }); }); it('should execute scene with empty house trigger', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -129,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: [ @@ -164,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: [ @@ -201,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: [ @@ -238,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: [ @@ -275,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: [ @@ -312,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: [ @@ -344,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: [ @@ -390,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: [ @@ -429,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 d53df5bd45..a310d1efb6 100644 --- a/server/test/lib/scene/scene.dailyUpdate.test.js +++ b/server/test/lib/scene/scene.dailyUpdate.test.js @@ -119,7 +119,7 @@ describe('SceneManager.dailyUpdate', () => { it('should schedule extra job for sunrise when a scene has offset=30', async () => { brain.addNamedEntity = fake.returns(null); - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'scene-offset', active: true, actions: [], @@ -150,7 +150,7 @@ describe('SceneManager.dailyUpdate', () => { it('should schedule extra job for sunset when a scene has negative offset=-15', async () => { brain.addNamedEntity = fake.returns(null); - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'scene-offset-neg', active: true, actions: [], @@ -180,7 +180,7 @@ describe('SceneManager.dailyUpdate', () => { it('should not add extra jobs for an inactive scene with a sunrise trigger', async () => { brain.addNamedEntity = fake.returns(null); - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'scene-inactive', active: false, actions: [], @@ -194,7 +194,7 @@ describe('SceneManager.dailyUpdate', () => { it('should not add extra jobs for a scene with no triggers', async () => { brain.addNamedEntity = fake.returns(null); - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'scene-no-triggers', active: true, actions: [], @@ -207,7 +207,7 @@ describe('SceneManager.dailyUpdate', () => { it('should ignore a non-numeric offset (string)', async () => { brain.addNamedEntity = fake.returns(null); - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'scene-bad-offset', active: true, actions: [], @@ -221,7 +221,7 @@ describe('SceneManager.dailyUpdate', () => { it('should ignore an offset exceeding 24h (offset > 1440)', async () => { brain.addNamedEntity = fake.returns(null); - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'scene-huge-offset', active: true, actions: [], @@ -235,7 +235,7 @@ describe('SceneManager.dailyUpdate', () => { it('should ignore a large negative offset exceeding 24h (offset < -1440)', async () => { brain.addNamedEntity = fake.returns(null); - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'scene-huge-neg-offset', active: true, actions: [], @@ -249,7 +249,7 @@ describe('SceneManager.dailyUpdate', () => { it('should deduplicate offsets when multiple scenes share the same offset', async () => { brain.addNamedEntity = fake.returns(null); - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'scene-a', active: true, actions: [], @@ -257,7 +257,7 @@ describe('SceneManager.dailyUpdate', () => { }); // flush first addScene auto-triggered dailyUpdate await Promise.resolve(); - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'scene-b', active: true, actions: [], 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/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 index b7915271c6..4d3e664bc4 100644 --- a/server/test/lib/scene/triggers/scene.trigger.sunriseSunset.test.js +++ b/server/test/lib/scene/triggers/scene.trigger.sunriseSunset.test.js @@ -54,7 +54,7 @@ describe('Scene.triggers.sunriseSunset', () => { }); it('should execute scene with sunrise trigger', async () => { - const addedScene = sceneManager.addScene({ + const addedScene = await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -91,7 +91,7 @@ describe('Scene.triggers.sunriseSunset', () => { }); it('should execute scene with sunset trigger', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -128,7 +128,7 @@ describe('Scene.triggers.sunriseSunset', () => { }); it('should not execute scene, sunrise trigger with offset=30 when event has offset=0', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -167,7 +167,7 @@ describe('Scene.triggers.sunriseSunset', () => { }); it('should execute scene, sunrise trigger with offset=30 when event has offset=30', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ @@ -206,7 +206,7 @@ describe('Scene.triggers.sunriseSunset', () => { }); it('should not execute scene, sunset trigger with offset=-15 when event has offset=0', async () => { - sceneManager.addScene({ + await sceneManager.addScene({ selector: 'my-scene', active: true, actions: [ From 24bbf93074790cb50f217bd093322d8bcfb541f4 Mon Sep 17 00:00:00 2001 From: Cyril Beslay Date: Sun, 26 Apr 2026 21:06:14 +0200 Subject: [PATCH 9/9] fix(scene): Timezone for logs --- server/lib/scene/scene.dailyUpdate.js | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/server/lib/scene/scene.dailyUpdate.js b/server/lib/scene/scene.dailyUpdate.js index 987e2d6dcf..e803acfcce 100644 --- a/server/lib/scene/scene.dailyUpdate.js +++ b/server/lib/scene/scene.dailyUpdate.js @@ -34,14 +34,8 @@ async function dailyUpdate() { // 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.get('hour')}:${sunriseBase.get('minute')}, in your timezone = ${ - this.timezone - }`, - ); - logger.info( - `Sunset today is at ${sunsetBase.get('hour')}:${sunsetBase.get('minute')}, in your timezone = ${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]); @@ -71,7 +65,11 @@ async function dailyUpdate() { this.event.emit(EVENTS.TRIGGERS.CHECK, { type: eventType, house, offset }), ); if (job) { - logger.info(`${label} (offset ${offset}min) is scheduled, ${dayjs(time).fromNow()}.`); + 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.`);