From 30bbaa36477b2277e30af1b9f1e951a01d797ec8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 17:05:52 +0000 Subject: [PATCH 1/2] feat(scene): add a sun position scene trigger (altitude & azimuth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new scene trigger "Sun position" that starts a scene when the sun reaches a given elevation above the horizon and/or a given compass direction, so that shutters can be opened/closed when the sun actually hits a facade. Server: - new events time.check-sun-position (internal, scheduled every minute in scene.init) and time.sun-position (the trigger type) - scene.checkSunPositionTriggers computes the sun position with suncalc for every located house, but only when at least one active scene uses the trigger. suncalc returns radians with an azimuth measured from South: it is converted to degrees with the usual compass convention (0 = North, 90 = East, 180 = South, 270 = West) - the trigger has an optional condition on the altitude and an optional one on the azimuth (operators >, < and =, "=" matching within 0.5°). The scene is started when the sun *enters* the configured area, so it is not re-executed at every check while the sun stays there - Joi validation of the new trigger properties Front: - new SunPositionTrigger component, wired in TriggerCard and in the trigger type list - en, fr and de translations Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013yxguVaLdJ8ZKmw5x3HePT --- front/src/config/i18n/de.json | 12 +- front/src/config/i18n/en.json | 12 +- front/src/config/i18n/fr.json | 12 +- .../routes/scene/edit-scene/TriggerCard.jsx | 9 + .../triggers/ChooseTriggerTypeCard.jsx | 1 + .../triggers/SunPositionTrigger.jsx | 198 ++++++++++++ server/lib/scene/index.js | 5 + .../scene/scene.checkSunPositionTriggers.js | 75 +++++ server/lib/scene/scene.init.js | 3 + server/lib/scene/scene.triggers.js | 48 +++ server/models/scene.js | 14 + .../scene.checkSunPositionTriggers.test.js | 123 ++++++++ server/test/lib/scene/scene.init.test.js | 4 +- .../scene.trigger.sunPosition.test.js | 281 ++++++++++++++++++ server/utils/constants.js | 2 + 15 files changed, 795 insertions(+), 4 deletions(-) create mode 100644 front/src/routes/scene/edit-scene/triggers/SunPositionTrigger.jsx create mode 100644 server/lib/scene/scene.checkSunPositionTriggers.js create mode 100644 server/test/lib/scene/scene.checkSunPositionTriggers.test.js create mode 100644 server/test/lib/scene/triggers/scene.trigger.sunPosition.test.js diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index 4703487ea2..c180f6ff62 100644 --- a/front/src/config/i18n/de.json +++ b/front/src/config/i18n/de.json @@ -3479,7 +3479,8 @@ "time": { "changed": "Zeitabhängiger Auslöser", "sunrise": "Jeder Sonnenaufgang", - "sunset": "Jeder Sonnenuntergang" + "sunset": "Jeder Sonnenuntergang", + "sun-position": "Sonnenstand" }, "user": { "back-home": "Benutzer ist zurück zu Hause", @@ -3575,6 +3576,15 @@ "after": "Nach", "minutes": "Minuten" }, + "sunPosition": { + "description": "Dies wird ausgelöst, wenn die Sonne die konfigurierte Position erreicht, zum Beispiel um die Rollläden der von der Sonne beschienenen Fassade zu öffnen oder zu schließen. Die Position wird jede Minute geprüft, und die Szene wird einmal gestartet, wenn die Sonne den konfigurierten Bereich erreicht.", + "houseLabel": "Zuhause", + "noCondition": "Keine Bedingung", + "altitudeLabel": "Sonnenhöhe", + "altitudeHelp": "Höhenwinkel der Sonne über dem Horizont, in Grad: 0° bei Sonnenaufgang und Sonnenuntergang, 90° im Zenit, nachts negativ.", + "azimuthLabel": "Sonnenazimut", + "azimuthHelp": "Himmelsrichtung der Sonne, in Grad ab Norden: 0° = Norden, 90° = Osten, 180° = Süden, 270° = Westen." + }, "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 3080417573..f8b338f68d 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -3479,7 +3479,8 @@ "time": { "changed": "Scheduled trigger", "sunrise": "Every sunrise", - "sunset": "Every sunset" + "sunset": "Every sunset", + "sun-position": "Sun position" }, "user": { "back-home": "User back at home", @@ -3575,6 +3576,15 @@ "after": "After", "minutes": "minutes" }, + "sunPosition": { + "description": "This will trigger when the sun reaches the position you configured, for example to open or close the shutters of the facade the sun is hitting. The position is checked every minute, and the scene is started once, when the sun enters the configured area.", + "houseLabel": "House", + "noCondition": "No condition", + "altitudeLabel": "Sun altitude", + "altitudeHelp": "Elevation angle of the sun above the horizon, in degrees: 0° at sunrise and sunset, 90° at the zenith, negative at night.", + "azimuthLabel": "Sun azimuth", + "azimuthHelp": "Compass direction of the sun, in degrees from North: 0° = North, 90° = East, 180° = South, 270° = West." + }, "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 152327809a..35128afbd6 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -3479,7 +3479,8 @@ "time": { "changed": "Déclenchement programmé", "sunrise": "A chaque lever de soleil", - "sunset": "A chaque coucher de soleil" + "sunset": "A chaque coucher de soleil", + "sun-position": "Position du soleil" }, "user": { "back-home": "Retour à la maison", @@ -3575,6 +3576,15 @@ "after": "Après", "minutes": "minutes" }, + "sunPosition": { + "description": "Cette scène se déclenchera lorsque le soleil atteindra la position configurée, par exemple pour ouvrir ou fermer les volets de la façade que le soleil éclaire. La position est vérifiée chaque minute, et la scène est démarrée une fois, lorsque le soleil entre dans la zone configurée.", + "houseLabel": "Maison", + "noCondition": "Aucune condition", + "altitudeLabel": "Altitude du soleil", + "altitudeHelp": "Hauteur du soleil au-dessus de l'horizon, en degrés : 0° au lever et au coucher du soleil, 90° au zénith, négative la nuit.", + "azimuthLabel": "Azimut du soleil", + "azimuthHelp": "Direction du soleil à la boussole, en degrés depuis le Nord : 0° = Nord, 90° = Est, 180° = Sud, 270° = Ouest." + }, "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/TriggerCard.jsx b/front/src/routes/scene/edit-scene/TriggerCard.jsx index 6b1e076495..61653a4178 100644 --- a/front/src/routes/scene/edit-scene/TriggerCard.jsx +++ b/front/src/routes/scene/edit-scene/TriggerCard.jsx @@ -5,6 +5,7 @@ import DeviceFeatureState from './triggers/DeviceFeatureState'; import ScheduledTrigger from './triggers/ScheduledTrigger'; import ChooseTriggerType from './triggers/ChooseTriggerTypeCard'; import SunriseSunsetTrigger from './triggers/SunriseSunsetTrigger'; +import SunPositionTrigger from './triggers/SunPositionTrigger'; import UserPresenceTrigger from './triggers/UserPresenceTrigger'; import HouseEmptyOrNot from './triggers/HouseEmptyOrNot'; import UserEnteredOrLeftArea from './triggers/UserEnteredOrLeftArea'; @@ -21,6 +22,7 @@ const TRIGGER_ICON = { [EVENTS.TIME.CHANGED]: 'fe-watch', [EVENTS.TIME.SUNSET]: 'fe-sunset', [EVENTS.TIME.SUNRISE]: 'fe-sunrise', + [EVENTS.TIME.SUN_POSITION]: 'fe-sun', [EVENTS.USER_PRESENCE.BACK_HOME]: 'fe-home', [EVENTS.USER_PRESENCE.LEFT_HOME]: 'fe-home', [EVENTS.HOUSE.NO_LONGER_EMPTY]: 'fe-home', @@ -107,6 +109,13 @@ const TriggerCard = ({ children, ...props }) => ( trigger={props.trigger} /> )} + {props.trigger.type === EVENTS.TIME.SUN_POSITION && ( + + )} {props.trigger.type === EVENTS.HOUSE.EMPTY && ( ', '<', '=']; + +class SunPositionTrigger extends Component { + getHouses = async () => { + this.setState({ + SceneGetHouses: RequestStatus.Getting + }); + try { + const houses = await this.props.httpClient.get('/api/v1/house'); + this.setState({ + houses, + SceneGetHouses: RequestStatus.Success + }); + } catch (e) { + this.setState({ + SceneGetHouses: RequestStatus.Error + }); + } + }; + + onHouseChange = e => { + this.props.updateTriggerProperty(this.props.index, 'house', e.target.value); + }; + + onAltitudeOperatorChange = e => { + this.props.updateTriggerProperty(this.props.index, 'sun_altitude_operator', e.target.value); + }; + + onAzimuthOperatorChange = e => { + this.props.updateTriggerProperty(this.props.index, 'sun_azimuth_operator', e.target.value); + }; + + onAltitudeChange = e => { + this.props.updateTriggerProperty(this.props.index, 'sun_altitude', this.parseDegree(e.target.value)); + }; + + onAzimuthChange = e => { + this.props.updateTriggerProperty(this.props.index, 'sun_azimuth', this.parseDegree(e.target.value)); + }; + + // An empty input is saved as null, so that the trigger stays valid while the + // user is typing, and the condition is simply not checked. + parseDegree = value => { + const degree = parseFloat(value); + return Number.isFinite(degree) ? degree : null; + }; + + houseIsValid(houseSelector) { + const { houses } = this.state; + let houseValid = false; + if (houseSelector === undefined || houseSelector === '') { + houseValid = true; + } else if (houses) { + const selectedHouse = houses.find(house => house.selector === houseSelector); + if (selectedHouse !== undefined) { + houseValid = selectedHouse.latitude && selectedHouse.longitude; + } + } + this.setState({ houseValid }); + } + + constructor(props) { + super(props); + this.state = { + houses: [], + houseValid: true + }; + } + + componentDidMount() { + this.getHouses(); + } + + componentDidUpdate(prevProps, prevState) { + if (prevState.houses !== this.state.houses || prevProps.trigger.house !== this.props.trigger.house) { + this.houseIsValid(this.props.trigger.house); + } + } + + render({ trigger }, { houses, houseValid }) { + return ( +
+

+ +

+
+
+ +
+ {!houseValid && ( +
+ +
+ )} + +
+
+
+ +
+
+
+ +
+
+
+ + + ° + +
+
+
+ + + +
+
+
+ +
+
+
+ +
+
+
+ + + ° + +
+
+
+ + + +
+
+ ); + } +} + +export default connect('httpClient,user', {})(SunPositionTrigger); diff --git a/server/lib/scene/index.js b/server/lib/scene/index.js index 7723b4526d..e26b3a0224 100644 --- a/server/lib/scene/index.js +++ b/server/lib/scene/index.js @@ -5,6 +5,7 @@ const { addScene } = require('./scene.addScene'); const { create } = require('./scene.create'); const { checkTrigger } = require('./scene.checkTrigger'); const { checkCalendarTriggers } = require('./scene.checkCalendarTriggers'); +const { checkSunPositionTriggers } = require('./scene.checkSunPositionTriggers'); const { init } = require('./scene.init'); const { cancelTriggers } = require('./scene.cancelTriggers'); const { destroy } = require('./scene.destroy'); @@ -56,6 +57,8 @@ const SceneManager = function SceneManager( this.sunCalc = sunCalc; this.scheduler = scheduler; this.jobs = []; + // Last known sun position (in degrees) per house selector + this.sunPositions = new Map(); this.checkTriggersDurationTimer = new Map(); this.event.on(EVENTS.TRIGGERS.CHECK, eventFunctionWrapper(this.checkTrigger.bind(this))); this.event.on(EVENTS.ACTION.TRIGGERED, eventFunctionWrapper(this.executeSingleAction.bind(this))); @@ -66,6 +69,7 @@ const SceneManager = function SceneManager( this.event.on(EVENTS.HOUSE.UPDATED, eventFunctionWrapper(this.dailyUpdate.bind(this))); this.event.on(EVENTS.HOUSE.DELETED, eventFunctionWrapper(this.dailyUpdate.bind(this))); this.event.on(EVENTS.CALENDAR.CHECK_IF_EVENT_IS_COMING, eventFunctionWrapper(this.checkCalendarTriggers.bind(this))); + this.event.on(EVENTS.TIME.CHECK_SUN_POSITION, eventFunctionWrapper(this.checkSunPositionTriggers.bind(this))); this.event.on(INTENTS.SCENE.START, this.command.bind(this)); @@ -76,6 +80,7 @@ SceneManager.prototype.addScene = addScene; SceneManager.prototype.cancelTriggers = cancelTriggers; SceneManager.prototype.create = create; SceneManager.prototype.checkCalendarTriggers = checkCalendarTriggers; +SceneManager.prototype.checkSunPositionTriggers = checkSunPositionTriggers; SceneManager.prototype.checkTrigger = checkTrigger; SceneManager.prototype.destroy = destroy; SceneManager.prototype.get = get; diff --git a/server/lib/scene/scene.checkSunPositionTriggers.js b/server/lib/scene/scene.checkSunPositionTriggers.js new file mode 100644 index 0000000000..4205e3511f --- /dev/null +++ b/server/lib/scene/scene.checkSunPositionTriggers.js @@ -0,0 +1,75 @@ +const logger = require('../../utils/logger'); +const { EVENTS } = require('../../utils/constants'); + +const RADIAN_TO_DEGREE = 180 / Math.PI; + +/** + * @description Convert a sun position returned by suncalc to degrees. + * Suncalc returns radians, with an azimuth measured from South going West + * (0 = South, PI/2 = West). Gladys exposes the usual compass convention + * instead: degrees from North (0 = North, 90 = East, 180 = South, 270 = West). + * @param {object} position - Position returned by sunCalc.getPosition. + * @param {number} position.altitude - Altitude in radians, above the horizon. + * @param {number} position.azimuth - Azimuth in radians, from South. + * @returns {object} The position in degrees, rounded to 2 decimals. + * @example + * const position = convertSunPositionToDegrees({ altitude: 0, azimuth: 0 }); + */ +function convertSunPositionToDegrees({ altitude, azimuth }) { + return { + altitude: Math.round(altitude * RADIAN_TO_DEGREE * 100) / 100, + azimuth: Math.round(((azimuth * RADIAN_TO_DEGREE + 180) % 360) * 100) / 100, + }; +} + +/** + * @description Run every minute to check if the sun position matches a scene trigger. + * @returns {Promise} Resolve when all houses have been checked. + * @example + * gladys.scene.checkSunPositionTriggers(); + */ +async function checkSunPositionTriggers() { + // Computing the sun position is cheap, but getting the houses is a DB call: + // we only do it when at least one active scene listens to this trigger. + const someSceneListens = Object.values(this.scenes).some( + (scene) => + scene.active && + scene.triggers instanceof Array && + scene.triggers.some((trigger) => trigger.type === EVENTS.TIME.SUN_POSITION), + ); + if (!someSceneListens) { + return; + } + const houses = await this.house.get(); + houses.forEach((house) => { + if (house.latitude === null || house.longitude === null) { + return; + } + const position = convertSunPositionToDegrees(this.sunCalc.getPosition(new Date(), house.latitude, house.longitude)); + const previousPosition = this.sunPositions.get(house.selector); + this.sunPositions.set(house.selector, position); + // The trigger fires when the sun enters the area configured by the user, so + // we need a previous position to compare with. On the first check of a house + // (Gladys just started), we only save the position. + if (!previousPosition) { + logger.debug(`Sun position in house ${house.selector}: first check, saving position.`); + return; + } + logger.debug( + `Sun position in house ${house.selector}: altitude = ${position.altitude}°, azimuth = ${position.azimuth}°.`, + ); + this.event.emit(EVENTS.TRIGGERS.CHECK, { + type: EVENTS.TIME.SUN_POSITION, + house, + altitude: position.altitude, + azimuth: position.azimuth, + previous_altitude: previousPosition.altitude, + previous_azimuth: previousPosition.azimuth, + }); + }); +} + +module.exports = { + checkSunPositionTriggers, + convertSunPositionToDegrees, +}; diff --git a/server/lib/scene/scene.init.js b/server/lib/scene/scene.init.js index c527eff4b7..79fa046914 100644 --- a/server/lib/scene/scene.init.js +++ b/server/lib/scene/scene.init.js @@ -39,6 +39,9 @@ async function init() { // At every minute, check if calendar event is coming this.scheduler.scheduleJob('* * * * *', () => this.event.emit(EVENTS.CALENDAR.CHECK_IF_EVENT_IS_COMING)); + // At every minute, check if the sun position matches a scene trigger + this.scheduler.scheduleJob('* * * * *', () => this.event.emit(EVENTS.TIME.CHECK_SUN_POSITION)); + return plainScenes; } diff --git a/server/lib/scene/scene.triggers.js b/server/lib/scene/scene.triggers.js index ff722b3418..6b0cfebe86 100644 --- a/server/lib/scene/scene.triggers.js +++ b/server/lib/scene/scene.triggers.js @@ -7,6 +7,53 @@ const { compare } = require('../../utils/compare'); const matchSunEvent = (self, sceneSelector, event, trigger) => event.house.selector === trigger.house && (event.offset || 0) === (trigger.offset || 0); +// Tolerance used by the "=" operator of the sun position trigger: the sun position is +// checked every minute, so it never matches an exact degree. "= 31" means +// "the sun altitude rounds to 31°". +const SUN_POSITION_EQUALITY_TOLERANCE_IN_DEGREE = 0.5; + +// A sun position trigger has an optional condition on the altitude and an optional +// one on the azimuth. An axis without operator or without value is not checked. +const getSunPositionCondition = (operator, value) => { + if (!operator || value === undefined || value === null || value === '') { + return null; + } + const numericValue = Number(value); + return Number.isFinite(numericValue) ? { operator, value: numericValue } : null; +}; + +const sunPositionAxisVerified = (condition, currentValue) => { + if (condition === null) { + return true; + } + if (condition.operator === '=') { + return Math.abs(currentValue - condition.value) <= SUN_POSITION_EQUALITY_TOLERANCE_IN_DEGREE; + } + return compare(condition.operator, currentValue, condition.value); +}; + +// Same house, and the sun just entered the area described by the trigger: the position is +// checked every minute, so the scene is executed when the conditions become verified, and +// not again at every check while the sun stays in the area. +const matchSunPosition = (self, sceneSelector, event, trigger) => { + if (event.house.selector !== trigger.house) { + return false; + } + const altitudeCondition = getSunPositionCondition(trigger.sun_altitude_operator, trigger.sun_altitude); + const azimuthCondition = getSunPositionCondition(trigger.sun_azimuth_operator, trigger.sun_azimuth); + // A trigger without any condition would match at every check: it never starts the scene. + if (altitudeCondition === null && azimuthCondition === null) { + return false; + } + const verified = + sunPositionAxisVerified(altitudeCondition, event.altitude) && + sunPositionAxisVerified(azimuthCondition, event.azimuth); + const previouslyVerified = + sunPositionAxisVerified(altitudeCondition, event.previous_altitude) && + sunPositionAxisVerified(azimuthCondition, event.previous_azimuth); + return verified && !previouslyVerified; +}; + // severity scale of the generic weather alerts (B.18) const WEATHER_ALERT_SEVERITY_RANK = { minor: 1, @@ -113,6 +160,7 @@ const triggersFunc = { [EVENTS.TIME.CHANGED]: (self, sceneSelector, event, trigger) => event.key === trigger.key, [EVENTS.TIME.SUNRISE]: matchSunEvent, [EVENTS.TIME.SUNSET]: matchSunEvent, + [EVENTS.TIME.SUN_POSITION]: matchSunPosition, [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/models/scene.js b/server/models/scene.js index 9142fa90ff..ab009de332 100644 --- a/server/models/scene.js +++ b/server/models/scene.js @@ -154,6 +154,20 @@ const triggersSchema = Joi.array().items( // weather-alert triggers (B.18): phenomenon type filter and minimal severity weather_alert_type: Joi.string().valid(...WEATHER_ALERT_TYPES, 'any'), weather_alert_severity: Joi.string().valid(...WEATHER_ALERT_SEVERITIES), + // sun position trigger: altitude in degrees above the horizon (-90 to 90) and + // azimuth in degrees from North (0 = North, 90 = East, 180 = South, 270 = West). + // Null is allowed so that a trigger can be saved while the user has not filled + // the value yet. + sun_altitude_operator: Joi.string().valid('=', '>', '<'), + sun_altitude: Joi.number() + .min(-90) + .max(90) + .allow(null), + sun_azimuth_operator: Joi.string().valid('=', '>', '<'), + sun_azimuth: Joi.number() + .min(0) + .max(360) + .allow(null), }), ); diff --git a/server/test/lib/scene/scene.checkSunPositionTriggers.test.js b/server/test/lib/scene/scene.checkSunPositionTriggers.test.js new file mode 100644 index 0000000000..399057413b --- /dev/null +++ b/server/test/lib/scene/scene.checkSunPositionTriggers.test.js @@ -0,0 +1,123 @@ +const { expect } = require('chai'); +const sinon = require('sinon').createSandbox(); + +const { assert, fake } = sinon; + +const SceneManager = require('../../../lib/scene'); +const { convertSunPositionToDegrees } = require('../../../lib/scene/scene.checkSunPositionTriggers'); +const { ACTIONS, EVENTS } = require('../../../utils/constants'); + +const DEGREE_TO_RADIAN = Math.PI / 180; + +const sunPositionScene = { + selector: 'sun-position-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_ON, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.TIME.SUN_POSITION, + house: 'my-house', + sun_altitude_operator: '>', + sun_altitude: 20, + }, + ], +}; + +describe('scene.checkSunPositionTriggers', () => { + const event = {}; + const house = {}; + const brain = {}; + const service = {}; + + let sceneManager; + + beforeEach(() => { + event.on = fake.returns(null); + event.emit = fake.returns(null); + brain.addNamedEntity = fake.returns(null); + brain.removeNamedEntity = fake.returns(null); + service.getService = fake.returns(null); + house.get = fake.resolves([ + { selector: 'my-house', latitude: 48.85, longitude: 2.35 }, + { selector: 'house-without-coordinates', latitude: null, longitude: null }, + ]); + + sceneManager = new SceneManager({}, event, {}, {}, {}, house, {}, {}, {}, {}, brain, service); + // The sun is at 30° above the horizon, in the South-East (azimuth 160° from North, + // so -20° from South in the suncalc convention) + sceneManager.sunCalc = { + getPosition: fake.returns({ + altitude: 30 * DEGREE_TO_RADIAN, + azimuth: -20 * DEGREE_TO_RADIAN, + }), + }; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should convert a suncalc position to degrees, with an azimuth from North', () => { + expect(convertSunPositionToDegrees({ altitude: 0, azimuth: 0 })).to.deep.equal({ altitude: 0, azimuth: 180 }); + expect(convertSunPositionToDegrees({ altitude: Math.PI / 2, azimuth: Math.PI })).to.deep.equal({ + altitude: 90, + azimuth: 0, + }); + expect(convertSunPositionToDegrees({ altitude: -Math.PI / 4, azimuth: -Math.PI / 2 })).to.deep.equal({ + altitude: -45, + azimuth: 90, + }); + }); + + it('should do nothing when no active scene has a sun position trigger', async () => { + await sceneManager.addScene({ + ...sunPositionScene, + triggers: [{ type: EVENTS.SYSTEM.START }], + }); + await sceneManager.checkSunPositionTriggers(); + assert.notCalled(house.get); + assert.notCalled(event.emit); + }); + + it('should do nothing when the scene with a sun position trigger is not active', async () => { + await sceneManager.addScene({ ...sunPositionScene, active: false }); + await sceneManager.checkSunPositionTriggers(); + assert.notCalled(house.get); + assert.notCalled(event.emit); + }); + + it('should only save the sun position at the first check', async () => { + await sceneManager.addScene(sunPositionScene); + await sceneManager.checkSunPositionTriggers(); + assert.notCalled(event.emit); + expect(sceneManager.sunPositions.get('my-house')).to.deep.equal({ altitude: 30, azimuth: 160 }); + // A house without coordinates is ignored + expect(sceneManager.sunPositions.get('house-without-coordinates')).to.equal(undefined); + }); + + it('should emit a trigger check with the current and previous sun position', async () => { + await sceneManager.addScene(sunPositionScene); + await sceneManager.checkSunPositionTriggers(); + sceneManager.sunCalc.getPosition = fake.returns({ + altitude: 31 * DEGREE_TO_RADIAN, + azimuth: -19 * DEGREE_TO_RADIAN, + }); + await sceneManager.checkSunPositionTriggers(); + assert.calledOnceWithExactly(event.emit, EVENTS.TRIGGERS.CHECK, { + type: EVENTS.TIME.SUN_POSITION, + house: { selector: 'my-house', latitude: 48.85, longitude: 2.35 }, + altitude: 31, + azimuth: 161, + previous_altitude: 30, + previous_azimuth: 160, + }); + expect(sceneManager.sunPositions.get('my-house')).to.deep.equal({ altitude: 31, azimuth: 161 }); + }); +}); diff --git a/server/test/lib/scene/scene.init.test.js b/server/test/lib/scene/scene.init.test.js index 6ea8ba4320..a34c419eb2 100644 --- a/server/test/lib/scene/scene.init.test.js +++ b/server/test/lib/scene/scene.init.test.js @@ -47,7 +47,7 @@ describe('scene.init', () => { it('should init scene all scheduled task', async () => { await sceneManager.init(); - assert.callCount(scheduler.scheduleJob, 4); + assert.callCount(scheduler.scheduleJob, 5); assert.calledWithMatch( scheduler.scheduleJob, { tz: 'Europe/Paris', hour: 0, minute: 0, second: 0 }, @@ -58,6 +58,8 @@ describe('scene.init', () => { // Check that scheduled method send an event scheduler.scheduleJob.getCall(3).callback(); assert.calledOnceWithExactly(event.emit, 'calendar.check-if-event-is-coming'); + scheduler.scheduleJob.getCall(4).callback(); + assert.calledWithExactly(event.emit, 'time.check-sun-position'); }); it('should call dailyUpdate only once during init, even with multiple sunrise/sunset scenes', async () => { await db.Scene.create({ diff --git a/server/test/lib/scene/triggers/scene.trigger.sunPosition.test.js b/server/test/lib/scene/triggers/scene.trigger.sunPosition.test.js new file mode 100644 index 0000000000..e38f1f82fb --- /dev/null +++ b/server/test/lib/scene/triggers/scene.trigger.sunPosition.test.js @@ -0,0 +1,281 @@ +const sinon = require('sinon').createSandbox(); +const { expect } = require('chai'); + +const { assert, fake } = sinon; + +const EventEmitter = require('events'); +const db = require('../../../../models'); +const StateManager = require('../../../../lib/state'); +const SceneManager = require('../../../../lib/scene'); +const { triggersFunc } = require('../../../../lib/scene/scene.triggers'); +const { ACTIONS, EVENTS } = require('../../../../utils/constants'); + +const event = new EventEmitter(); + +// The sun is in the South-East, 30° above the horizon +const sunPositionEvent = { + type: EVENTS.TIME.SUN_POSITION, + house: { + selector: 'my-house', + }, + altitude: 30, + azimuth: 160, + previous_altitude: 29, + previous_azimuth: 159, +}; + +describe('Scene.triggers.sunPosition', () => { + let sceneManager; + + const device = { + setValue: fake.resolves(null), + }; + + const brain = {}; + + const service = { + getService: 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 the scene when the sun enters the configured area', async () => { + await sceneManager.addScene({ + selector: 'sun-position-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_ON, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.TIME.SUN_POSITION, + house: 'my-house', + sun_altitude_operator: '>', + sun_altitude: 29.5, + sun_azimuth_operator: '>', + sun_azimuth: 159.5, + }, + ], + }); + sceneManager.checkTrigger(sunPositionEvent); + return new Promise((resolve, reject) => { + sceneManager.queue.start(() => { + try { + assert.calledOnce(device.setValue); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); + + it('should not execute the scene when the house is not matching', async () => { + await sceneManager.addScene({ + selector: 'sun-position-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_ON, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.TIME.SUN_POSITION, + house: 'another-house', + sun_altitude_operator: '>', + sun_altitude: 29.5, + }, + ], + }); + sceneManager.checkTrigger(sunPositionEvent); + return new Promise((resolve, reject) => { + sceneManager.queue.start(() => { + try { + assert.notCalled(device.setValue); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); + + it('should match when only the altitude condition is set', () => { + const trigger = { + type: EVENTS.TIME.SUN_POSITION, + house: 'my-house', + sun_altitude_operator: '>', + sun_altitude: 29.5, + }; + expect(triggersFunc[EVENTS.TIME.SUN_POSITION](sceneManager, 'scene-1', sunPositionEvent, trigger)).to.equal(true); + }); + + it('should match when only the azimuth condition is set', () => { + const trigger = { + type: EVENTS.TIME.SUN_POSITION, + house: 'my-house', + sun_azimuth_operator: '<', + sun_azimuth: 159.5, + }; + const sunGoingWest = { ...sunPositionEvent, azimuth: 159, previous_azimuth: 160 }; + expect(triggersFunc[EVENTS.TIME.SUN_POSITION](sceneManager, 'scene-1', sunGoingWest, trigger)).to.equal(true); + }); + + it('should match with the "=" operator when the sun altitude rounds to the value', () => { + const trigger = { + type: EVENTS.TIME.SUN_POSITION, + house: 'my-house', + sun_altitude_operator: '=', + sun_altitude: 31, + }; + const sunAlmostAt31 = { ...sunPositionEvent, altitude: 30.7, previous_altitude: 30.2 }; + expect(triggersFunc[EVENTS.TIME.SUN_POSITION](sceneManager, 'scene-1', sunAlmostAt31, trigger)).to.equal(true); + }); + + it('should not match with the "=" operator when the sun altitude is too far from the value', () => { + const trigger = { + type: EVENTS.TIME.SUN_POSITION, + house: 'my-house', + sun_altitude_operator: '=', + sun_altitude: 31, + }; + expect(triggersFunc[EVENTS.TIME.SUN_POSITION](sceneManager, 'scene-1', sunPositionEvent, trigger)).to.equal(false); + }); + + it('should not match twice while the sun stays in the configured area', () => { + const trigger = { + type: EVENTS.TIME.SUN_POSITION, + house: 'my-house', + sun_altitude_operator: '>', + sun_altitude: 20, + }; + // The previous altitude was already above the threshold + expect(triggersFunc[EVENTS.TIME.SUN_POSITION](sceneManager, 'scene-1', sunPositionEvent, trigger)).to.equal(false); + }); + + it('should not match when the altitude condition is verified but not the azimuth one', () => { + const trigger = { + type: EVENTS.TIME.SUN_POSITION, + house: 'my-house', + sun_altitude_operator: '>', + sun_altitude: 29.5, + sun_azimuth_operator: '>', + sun_azimuth: 200, + }; + expect(triggersFunc[EVENTS.TIME.SUN_POSITION](sceneManager, 'scene-1', sunPositionEvent, trigger)).to.equal(false); + }); + + it('should not match when no condition is configured', () => { + const trigger = { + type: EVENTS.TIME.SUN_POSITION, + house: 'my-house', + }; + expect(triggersFunc[EVENTS.TIME.SUN_POSITION](sceneManager, 'scene-1', sunPositionEvent, trigger)).to.equal(false); + }); + + it('should not match when an operator is set without any value', () => { + const trigger = { + type: EVENTS.TIME.SUN_POSITION, + house: 'my-house', + sun_altitude_operator: '>', + sun_altitude: null, + }; + expect(triggersFunc[EVENTS.TIME.SUN_POSITION](sceneManager, 'scene-1', sunPositionEvent, trigger)).to.equal(false); + }); + + it('should not match when the value is not a number', () => { + const trigger = { + type: EVENTS.TIME.SUN_POSITION, + house: 'my-house', + sun_altitude_operator: '>', + sun_altitude: 'not-a-number', + }; + expect(triggersFunc[EVENTS.TIME.SUN_POSITION](sceneManager, 'scene-1', sunPositionEvent, trigger)).to.equal(false); + }); + + it('should save a scene with a sun position trigger in database', async () => { + const sceneInDb = await db.Scene.create({ + name: 'sun-position-scene-in-db', + icon: 'sun', + triggers: [ + { + type: EVENTS.TIME.SUN_POSITION, + house: 'my-house', + sun_altitude_operator: '=', + sun_altitude: 31, + sun_azimuth_operator: '=', + sun_azimuth: 160, + }, + ], + actions: [[]], + }); + expect(sceneInDb.triggers[0].sun_azimuth).to.equal(160); + }); + + it('should reject a scene with an azimuth out of range', async () => { + let error; + try { + await db.Scene.create({ + name: 'sun-position-scene-invalid', + icon: 'sun', + triggers: [ + { + type: EVENTS.TIME.SUN_POSITION, + house: 'my-house', + sun_azimuth_operator: '=', + sun_azimuth: 400, + }, + ], + actions: [[]], + }); + } catch (e) { + error = e; + } + expect(error).to.not.equal(undefined); + expect(error.message).to.contain('sun_azimuth'); + }); + + it('should match when the value is a numeric string', () => { + const trigger = { + type: EVENTS.TIME.SUN_POSITION, + house: 'my-house', + sun_altitude_operator: '>', + sun_altitude: '29.5', + }; + expect(triggersFunc[EVENTS.TIME.SUN_POSITION](sceneManager, 'scene-1', sunPositionEvent, trigger)).to.equal(true); + }); +}); diff --git a/server/utils/constants.js b/server/utils/constants.js index a266424629..cafd5b2ac8 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -545,6 +545,8 @@ const EVENTS = { CHANGED: 'time.changed', SUNRISE: 'time.sunrise', SUNSET: 'time.sunset', + CHECK_SUN_POSITION: 'time.check-sun-position', + SUN_POSITION: 'time.sun-position', }, TRIGGERS: { CHECK: 'trigger.check', From b93b1fd1122b5ad0943fccefd5e2a92191ec784c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 19:56:30 +0000 Subject: [PATCH 2/2] fix(scene): normalize the sun azimuth around North in the sun position trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on the sun position trigger: - convertSunPositionToDegrees now normalizes the azimuth into [0, 360[ after rounding: an azimuth just below North used to round to exactly 360 instead of 0 - the "=" operator now uses the circular distance on the azimuth axis, so "= 0" also matches an azimuth of 359.8°. The altitude keeps a plain linear distance, and ">" / "<" stay plain comparisons on [0, 360[: an azimuth area crossing North cannot be described with a single condition, which is now documented in the code and in the UI help text - the azimuth help text (en, fr, de) explains that the sun is due North below the horizon around solar midnight, so an azimuth condition should be combined with an altitude one to only start the scene during the day Autofix-Pass: 1 --- front/src/config/i18n/de.json | 2 +- front/src/config/i18n/en.json | 2 +- front/src/config/i18n/fr.json | 2 +- .../scene/scene.checkSunPositionTriggers.js | 8 +++++- server/lib/scene/scene.triggers.js | 27 ++++++++++++++----- .../scene.checkSunPositionTriggers.test.js | 8 ++++++ .../scene.trigger.sunPosition.test.js | 23 ++++++++++++++++ 7 files changed, 62 insertions(+), 10 deletions(-) diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index c180f6ff62..7924264ad3 100644 --- a/front/src/config/i18n/de.json +++ b/front/src/config/i18n/de.json @@ -3583,7 +3583,7 @@ "altitudeLabel": "Sonnenhöhe", "altitudeHelp": "Höhenwinkel der Sonne über dem Horizont, in Grad: 0° bei Sonnenaufgang und Sonnenuntergang, 90° im Zenit, nachts negativ.", "azimuthLabel": "Sonnenazimut", - "azimuthHelp": "Himmelsrichtung der Sonne, in Grad ab Norden: 0° = Norden, 90° = Osten, 180° = Süden, 270° = Westen." + "azimuthHelp": "Himmelsrichtung der Sonne, in Grad ab Norden: 0° = Norden, 90° = Osten, 180° = Süden, 270° = Westen. Um die wahre Mitternacht herum steht die Sonne genau im Norden, unter dem Horizont: Kombinieren Sie den Azimut mit einer Bedingung für die Sonnenhöhe (zum Beispiel Höhe > 0), wenn die Szene nur tagsüber starten soll. Eine einzelne Bedingung kann keinen Bereich beschreiben, der über Norden hinweggeht, zum Beispiel von 350° bis 10°." }, "userPresence": { "backAtHomeDescription": "Dies wird ausgelöst, wenn der ausgewählte Benutzer wieder im ausgewählten Zuhause ist.", diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index f8b338f68d..3c8222ab5c 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -3583,7 +3583,7 @@ "altitudeLabel": "Sun altitude", "altitudeHelp": "Elevation angle of the sun above the horizon, in degrees: 0° at sunrise and sunset, 90° at the zenith, negative at night.", "azimuthLabel": "Sun azimuth", - "azimuthHelp": "Compass direction of the sun, in degrees from North: 0° = North, 90° = East, 180° = South, 270° = West." + "azimuthHelp": "Compass direction of the sun, in degrees from North: 0° = North, 90° = East, 180° = South, 270° = West. Around solar midnight the sun is due North, below the horizon: combine the azimuth with an altitude condition (for example altitude > 0) if the scene must only start during the day. A single condition cannot describe an area crossing North, for example from 350° to 10°." }, "userPresence": { "backAtHomeDescription": "This will trigger when the selected user is back at the selected home.", diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 35128afbd6..de3bd1fea0 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -3583,7 +3583,7 @@ "altitudeLabel": "Altitude du soleil", "altitudeHelp": "Hauteur du soleil au-dessus de l'horizon, en degrés : 0° au lever et au coucher du soleil, 90° au zénith, négative la nuit.", "azimuthLabel": "Azimut du soleil", - "azimuthHelp": "Direction du soleil à la boussole, en degrés depuis le Nord : 0° = Nord, 90° = Est, 180° = Sud, 270° = Ouest." + "azimuthHelp": "Direction du soleil à la boussole, en degrés depuis le Nord : 0° = Nord, 90° = Est, 180° = Sud, 270° = Ouest. Autour du minuit solaire, le soleil est plein Nord, sous l'horizon : combinez l'azimut avec une condition sur l'altitude (par exemple altitude > 0) si la scène ne doit démarrer que le jour. Une seule condition ne peut pas décrire une zone qui traverse le Nord, par exemple de 350° à 10°." }, "userPresence": { "backAtHomeDescription": "Cette scène s'exécutera lorsque l'utilisateur sélectionné rentrera à la maison sélectionnée.", diff --git a/server/lib/scene/scene.checkSunPositionTriggers.js b/server/lib/scene/scene.checkSunPositionTriggers.js index 4205e3511f..332a15ccf5 100644 --- a/server/lib/scene/scene.checkSunPositionTriggers.js +++ b/server/lib/scene/scene.checkSunPositionTriggers.js @@ -16,9 +16,15 @@ const RADIAN_TO_DEGREE = 180 / Math.PI; * const position = convertSunPositionToDegrees({ altitude: 0, azimuth: 0 }); */ function convertSunPositionToDegrees({ altitude, azimuth }) { + // The JS remainder keeps the sign of its left operand, so a second modulo is needed + // to always land in [0, 360[. + const azimuthInDegree = (((azimuth * RADIAN_TO_DEGREE + 180) % 360) + 360) % 360; + const roundedAzimuth = Math.round(azimuthInDegree * 100) / 100; return { altitude: Math.round(altitude * RADIAN_TO_DEGREE * 100) / 100, - azimuth: Math.round(((azimuth * RADIAN_TO_DEGREE + 180) % 360) * 100) / 100, + // Rounding an azimuth just below North (359.999°) gives exactly 360: bring it back + // to 0, so that North is always exposed as 0 and never as 360. + azimuth: roundedAzimuth >= 360 ? roundedAzimuth - 360 : roundedAzimuth, }; } diff --git a/server/lib/scene/scene.triggers.js b/server/lib/scene/scene.triggers.js index 6b0cfebe86..da616d50ac 100644 --- a/server/lib/scene/scene.triggers.js +++ b/server/lib/scene/scene.triggers.js @@ -22,12 +22,27 @@ const getSunPositionCondition = (operator, value) => { return Number.isFinite(numericValue) ? { operator, value: numericValue } : null; }; -const sunPositionAxisVerified = (condition, currentValue) => { +// The azimuth is an angle on a compass: 359.9° and 0.1° are 0.2° apart, not 359.8°. +const circularDistanceInDegree = (a, b) => { + const distance = Math.abs(a - b) % 360; + return Math.min(distance, 360 - distance); +}; + +// `circular` is true for the azimuth only: the altitude is a plain elevation angle. +// It changes how "=" measures the distance to the configured value, so that "= 0" +// also matches an azimuth of 359.8°. ">" and "<" stay plain comparisons on [0, 360[, +// even for the azimuth: a half-plane has no wrap-aware meaning on a circle, so an +// azimuth area crossing North (for example from 350° to 10°) cannot be described with +// a single condition. The UI help text documents it. +const sunPositionAxisVerified = (condition, currentValue, circular) => { if (condition === null) { return true; } if (condition.operator === '=') { - return Math.abs(currentValue - condition.value) <= SUN_POSITION_EQUALITY_TOLERANCE_IN_DEGREE; + const distance = circular + ? circularDistanceInDegree(currentValue, condition.value) + : Math.abs(currentValue - condition.value); + return distance <= SUN_POSITION_EQUALITY_TOLERANCE_IN_DEGREE; } return compare(condition.operator, currentValue, condition.value); }; @@ -46,11 +61,11 @@ const matchSunPosition = (self, sceneSelector, event, trigger) => { return false; } const verified = - sunPositionAxisVerified(altitudeCondition, event.altitude) && - sunPositionAxisVerified(azimuthCondition, event.azimuth); + sunPositionAxisVerified(altitudeCondition, event.altitude, false) && + sunPositionAxisVerified(azimuthCondition, event.azimuth, true); const previouslyVerified = - sunPositionAxisVerified(altitudeCondition, event.previous_altitude) && - sunPositionAxisVerified(azimuthCondition, event.previous_azimuth); + sunPositionAxisVerified(altitudeCondition, event.previous_altitude, false) && + sunPositionAxisVerified(azimuthCondition, event.previous_azimuth, true); return verified && !previouslyVerified; }; diff --git a/server/test/lib/scene/scene.checkSunPositionTriggers.test.js b/server/test/lib/scene/scene.checkSunPositionTriggers.test.js index 399057413b..6c3c2a29ef 100644 --- a/server/test/lib/scene/scene.checkSunPositionTriggers.test.js +++ b/server/test/lib/scene/scene.checkSunPositionTriggers.test.js @@ -76,6 +76,14 @@ describe('scene.checkSunPositionTriggers', () => { }); }); + it('should convert an azimuth just below North to 0 and never to 360', () => { + // Just below North: the rounding to 2 decimals would give 360 without the normalization + const justBelowNorth = convertSunPositionToDegrees({ altitude: 0, azimuth: Math.PI - 0.0000001 }); + expect(justBelowNorth.azimuth).to.equal(0); + const justAboveNorth = convertSunPositionToDegrees({ altitude: 0, azimuth: -Math.PI + 0.0000001 }); + expect(justAboveNorth.azimuth).to.equal(0); + }); + it('should do nothing when no active scene has a sun position trigger', async () => { await sceneManager.addScene({ ...sunPositionScene, diff --git a/server/test/lib/scene/triggers/scene.trigger.sunPosition.test.js b/server/test/lib/scene/triggers/scene.trigger.sunPosition.test.js index e38f1f82fb..def9eb2071 100644 --- a/server/test/lib/scene/triggers/scene.trigger.sunPosition.test.js +++ b/server/test/lib/scene/triggers/scene.trigger.sunPosition.test.js @@ -176,6 +176,29 @@ describe('Scene.triggers.sunPosition', () => { expect(triggersFunc[EVENTS.TIME.SUN_POSITION](sceneManager, 'scene-1', sunPositionEvent, trigger)).to.equal(false); }); + it('should match with the "=" operator on the azimuth across North', () => { + const trigger = { + type: EVENTS.TIME.SUN_POSITION, + house: 'my-house', + sun_azimuth_operator: '=', + sun_azimuth: 0, + }; + // The sun is 0.2° west of North: the circular distance to 0° is 0.2°, not 359.8° + const sunAlmostNorth = { ...sunPositionEvent, azimuth: 359.8, previous_azimuth: 359 }; + expect(triggersFunc[EVENTS.TIME.SUN_POSITION](sceneManager, 'scene-1', sunAlmostNorth, trigger)).to.equal(true); + }); + + it('should not match with the "=" operator when the azimuth is far from the value on the compass', () => { + const trigger = { + type: EVENTS.TIME.SUN_POSITION, + house: 'my-house', + sun_azimuth_operator: '=', + sun_azimuth: 180, + }; + const sunAlmostNorth = { ...sunPositionEvent, azimuth: 359.8, previous_azimuth: 359 }; + expect(triggersFunc[EVENTS.TIME.SUN_POSITION](sceneManager, 'scene-1', sunAlmostNorth, trigger)).to.equal(false); + }); + it('should not match twice while the sun stays in the configured area', () => { const trigger = { type: EVENTS.TIME.SUN_POSITION,