From 817384efef38f7e67d8a36f655aa06fdab0b0f0f Mon Sep 17 00:00:00 2001 From: William Deren Date: Sun, 23 Aug 2026 11:12:06 +0200 Subject: [PATCH 01/29] feat(thermostat): add the weekly schedule tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thermostat schedule is a name and a list of slots: a day of the week, a start and end time in HH:MM, and the preset to apply. Slots are constrained at the model level — day 0-6, preset within the known set — because an invalid slot would be stored happily and then silently match nothing at regulation time. SQLite has no native ENUM, so the preset is also checked explicitly. A slot ending at 00:00 means end of day, and a slot whose end is before its start crosses midnight; that is what makes a single "22:00 → 06:00 night" slot expressible. The regulation defaults live in server/utils/thermostatConstants.js, in utils/ rather than the service directory so the models and the frontend can import them without pulling the service layer in. Co-Authored-By: Claude Opus 5 --- ...260823000000-create-thermostat-schedule.js | 83 +++++++++++++++++++ server/models/index.js | 4 + server/models/thermostat_schedule.js | 39 +++++++++ server/models/thermostat_schedule_slot.js | 58 +++++++++++++ .../test/models/thermostat_schedule.test.js | 79 ++++++++++++++++++ server/utils/thermostatConstants.js | 54 ++++++++++++ 6 files changed, 317 insertions(+) create mode 100644 server/migrations/20260823000000-create-thermostat-schedule.js create mode 100644 server/models/thermostat_schedule.js create mode 100644 server/models/thermostat_schedule_slot.js create mode 100644 server/test/models/thermostat_schedule.test.js create mode 100644 server/utils/thermostatConstants.js diff --git a/server/migrations/20260823000000-create-thermostat-schedule.js b/server/migrations/20260823000000-create-thermostat-schedule.js new file mode 100644 index 0000000000..0f1347912d --- /dev/null +++ b/server/migrations/20260823000000-create-thermostat-schedule.js @@ -0,0 +1,83 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable('t_thermostat_schedule', { + id: { + allowNull: false, + primaryKey: true, + type: Sequelize.UUID, + }, + name: { + allowNull: false, + type: Sequelize.STRING, + }, + selector: { + allowNull: false, + unique: true, + type: Sequelize.STRING, + }, + created_at: { + allowNull: false, + type: Sequelize.DATE, + }, + updated_at: { + allowNull: false, + type: Sequelize.DATE, + }, + }); + + await queryInterface.createTable('t_thermostat_schedule_slot', { + id: { + allowNull: false, + primaryKey: true, + type: Sequelize.UUID, + }, + schedule_id: { + allowNull: false, + type: Sequelize.UUID, + references: { + model: 't_thermostat_schedule', + key: 'id', + }, + onDelete: 'CASCADE', + }, + day_of_week: { + allowNull: false, + type: Sequelize.INTEGER, + // The 0-6 range is enforced by the model and by the Joi schema on write: + // `validate` is a model-level option and does nothing in createTable. + comment: '0=Monday, 1=Tuesday, ..., 6=Sunday', + }, + start_time: { + allowNull: false, + type: Sequelize.STRING, + comment: 'HH:MM format', + }, + end_time: { + allowNull: false, + type: Sequelize.STRING, + comment: 'HH:MM format', + }, + preset: { + allowNull: false, + type: Sequelize.ENUM('off', 'frost', 'away', 'eco', 'night', 'comfort'), + comment: 'off, frost, away, eco, night, comfort', + }, + created_at: { + allowNull: false, + type: Sequelize.DATE, + }, + updated_at: { + allowNull: false, + type: Sequelize.DATE, + }, + }); + + await queryInterface.addIndex('t_thermostat_schedule_slot', ['schedule_id']); + await queryInterface.addIndex('t_thermostat_schedule_slot', ['day_of_week']); + }, + + down: async (queryInterface) => { + await queryInterface.dropTable('t_thermostat_schedule_slot'); + await queryInterface.dropTable('t_thermostat_schedule'); + }, +}; diff --git a/server/models/index.js b/server/models/index.js index 9a4f2e029a..8cf15d67ff 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -39,6 +39,8 @@ const DeviceFeatureSupportedOptionModel = require('./device_feature_supported_op const DeviceParamModel = require('./device_param'); const DeviceModel = require('./device'); const EnergyPriceModel = require('./energy_price'); +const ThermostatScheduleModel = require('./thermostat_schedule'); +const ThermostatScheduleSlotModel = require('./thermostat_schedule_slot'); const HouseModel = require('./house'); const JobModel = require('./job'); const LifeEventModel = require('./life_event'); @@ -67,6 +69,8 @@ const models = { DeviceParam: DeviceParamModel(sequelize, Sequelize), Device: DeviceModel(sequelize, Sequelize), EnergyPrice: EnergyPriceModel(sequelize, Sequelize), + ThermostatSchedule: ThermostatScheduleModel(sequelize, Sequelize), + ThermostatScheduleSlot: ThermostatScheduleSlotModel(sequelize, Sequelize), House: HouseModel(sequelize, Sequelize), Job: JobModel(sequelize, Sequelize), LifeEvent: LifeEventModel(sequelize, Sequelize), diff --git a/server/models/thermostat_schedule.js b/server/models/thermostat_schedule.js new file mode 100644 index 0000000000..67b42a83bf --- /dev/null +++ b/server/models/thermostat_schedule.js @@ -0,0 +1,39 @@ +const { slugify } = require('../utils/slugify'); + +module.exports = (sequelize, DataTypes) => { + const thermostatSchedule = sequelize.define( + 't_thermostat_schedule', + { + id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + }, + name: { + allowNull: false, + type: DataTypes.STRING, + }, + selector: { + allowNull: false, + unique: true, + type: DataTypes.STRING, + }, + }, + {}, + ); + + thermostatSchedule.beforeValidate((item) => { + if (item.isNewRecord && !item.selector) { + item.selector = slugify(`${item.name}-${Date.now()}`, true); + } + }); + + thermostatSchedule.associate = (models) => { + thermostatSchedule.hasMany(models.ThermostatScheduleSlot, { + foreignKey: 'schedule_id', + as: 'slots', + }); + }; + + return thermostatSchedule; +}; diff --git a/server/models/thermostat_schedule_slot.js b/server/models/thermostat_schedule_slot.js new file mode 100644 index 0000000000..e8b9d9de6e --- /dev/null +++ b/server/models/thermostat_schedule_slot.js @@ -0,0 +1,58 @@ +const { PRESETS } = require('../utils/thermostatConstants'); + +module.exports = (sequelize, DataTypes) => { + const thermostatScheduleSlot = sequelize.define( + 't_thermostat_schedule_slot', + { + id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + }, + schedule_id: { + allowNull: false, + type: DataTypes.UUID, + references: { + model: 't_thermostat_schedule', + key: 'id', + }, + }, + day_of_week: { + allowNull: false, + type: DataTypes.INTEGER, + validate: { + min: 0, + max: 6, + }, + }, + start_time: { + allowNull: false, + type: DataTypes.STRING, + }, + end_time: { + allowNull: false, + type: DataTypes.STRING, + }, + preset: { + allowNull: false, + // SQLite has no native ENUM, so the value is checked explicitly: + // an unknown preset would be stored and then match nothing at + // regulation time. + type: DataTypes.ENUM(...PRESETS), + validate: { + isIn: [PRESETS], + }, + }, + }, + {}, + ); + + thermostatScheduleSlot.associate = (models) => { + thermostatScheduleSlot.belongsTo(models.ThermostatSchedule, { + foreignKey: 'schedule_id', + as: 'schedule', + }); + }; + + return thermostatScheduleSlot; +}; diff --git a/server/test/models/thermostat_schedule.test.js b/server/test/models/thermostat_schedule.test.js new file mode 100644 index 0000000000..e0146fc2cf --- /dev/null +++ b/server/test/models/thermostat_schedule.test.js @@ -0,0 +1,79 @@ +const { expect } = require('chai'); + +const db = require('../../models'); + +// build(...).validate() runs the model validators and the beforeValidate hook +// that derives the selector, without touching the database. +describe('models/thermostat_schedule', () => { + it('should derive a selector from the name on a new record', async () => { + const schedule = db.ThermostatSchedule.build({ name: 'Semaine de travail' }); + + await schedule.validate(); + + expect(schedule.selector).to.match(/^semaine-de-travail-\d+/); + }); + + it('should keep a selector that was provided', async () => { + const schedule = db.ThermostatSchedule.build({ name: 'Semaine', selector: 'my-own-selector' }); + + await schedule.validate(); + + expect(schedule.selector).to.equal('my-own-selector'); + }); + + it('should require a name', async () => { + let error = null; + try { + await db.ThermostatSchedule.build({ selector: 'no-name' }).validate(); + } catch (e) { + error = e; + } + + expect(error).to.not.equal(null); + }); +}); + +describe('models/thermostat_schedule_slot', () => { + const buildSlot = (overrides = {}) => + db.ThermostatScheduleSlot.build({ + schedule_id: 'a810b8db-6d04-4697-bed3-c4b72c996279', + day_of_week: 0, + start_time: '07:00', + end_time: '09:00', + preset: 'comfort', + ...overrides, + }); + + const expectInvalid = async (overrides) => { + let error = null; + try { + await buildSlot(overrides).validate(); + } catch (e) { + error = e; + } + expect(error, `expected ${JSON.stringify(overrides)} to be rejected`).to.not.equal(null); + }; + + it('should accept a valid slot', async () => { + await buildSlot().validate(); + }); + + it('should accept every day of the week', async () => { + await Promise.all([0, 1, 2, 3, 4, 5, 6].map((day) => buildSlot({ day_of_week: day }).validate())); + }); + + it('should reject a day outside the week', async () => { + await expectInvalid({ day_of_week: 7 }); + await expectInvalid({ day_of_week: -1 }); + }); + + it('should accept every known preset', async () => { + await Promise.all( + ['off', 'frost', 'away', 'eco', 'night', 'comfort'].map((preset) => buildSlot({ preset }).validate()), + ); + }); + + it('should reject an unknown preset', async () => { + await expectInvalid({ preset: 'party' }); + }); +}); diff --git a/server/utils/thermostatConstants.js b/server/utils/thermostatConstants.js new file mode 100644 index 0000000000..055f5fe8fb --- /dev/null +++ b/server/utils/thermostatConstants.js @@ -0,0 +1,54 @@ +// Single source of truth for the thermostat defaults, shared by the server +// regulation loop, the integration edit page and the dashboard widget. Keeping +// them here avoids the drift where the form offered one default and the +// regulation applied another. + +const DEFAULT_PRESET_TEMPS = { + frost: 7, + away: 16, + eco: 18, + night: 17, + comfort: 21, +}; + +// Fallback setpoint for a preset that is neither known nor configured. +const FALLBACK_SETPOINT = 20; + +const DEFAULT_HYSTERESIS_START = 0.5; +const DEFAULT_HYSTERESIS_STOP = 0.5; +// Minutes. The integration form offers the same value, so a device saved +// without the param is regulated exactly as the form displayed it. +const DEFAULT_TPI_CYCLE_TIME = 30; +const DEFAULT_TPI_PROPORTIONAL_BAND = 2; + +const DEFAULT_MODE = 'heating'; +const DEFAULT_CONTROL_TYPE = 'hysteresis'; + +const DEFAULT_MIN_TEMP = 5; +const DEFAULT_MAX_TEMP = 35; +const DEFAULT_TEMP_UNIT = 'C'; + +// How long a manual setpoint (widget dial or scene) holds before the schedule +// takes over again. Configurable per device through THERMOSTAT_MANUAL_DURATION, +// which is expressed in minutes; this is the fallback when it is unset. +const DEFAULT_MANUAL_DURATION_MINUTES = 30; +const MANUAL_DURATION_MS = DEFAULT_MANUAL_DURATION_MINUTES * 60 * 1000; + +const PRESETS = ['off', 'frost', 'away', 'eco', 'night', 'comfort']; + +module.exports = { + DEFAULT_PRESET_TEMPS, + FALLBACK_SETPOINT, + DEFAULT_HYSTERESIS_START, + DEFAULT_HYSTERESIS_STOP, + DEFAULT_TPI_CYCLE_TIME, + DEFAULT_TPI_PROPORTIONAL_BAND, + DEFAULT_MODE, + DEFAULT_CONTROL_TYPE, + DEFAULT_MIN_TEMP, + DEFAULT_MAX_TEMP, + DEFAULT_TEMP_UNIT, + DEFAULT_MANUAL_DURATION_MINUTES, + MANUAL_DURATION_MS, + PRESETS, +}; From 01e6ec8903697f4fce5cd24029fc5950dc17268b Mon Sep 17 00:00:00 2001 From: William Deren Date: Sun, 23 Aug 2026 11:12:20 +0200 Subject: [PATCH 02/29] feat(thermostat): add the schedule matching and validation helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the server regulation loop and the dashboard widget need to answer "which slot covers right now?", and they must agree: a widget showing comfort while the server heats on eco is a bug report waiting to happen. One module, imported by both. Schedules are wall-clock times in the house, so the day and minute are read in the timezone Gladys is configured with rather than the process one — the official Docker image runs in UTC, which would shift every slot by the local offset. Unknown zone names fall back to the process timezone. The module lives in server/utils/ because the frontend build only aliases server/utils/*: putting it under the service directory would let a later require('../models') break the Vite build. Slot validation is shared too, so the API and the model agree on what a well-formed slot is. Co-Authored-By: Claude Opus 5 --- server/test/utils/thermostatSchedule.test.js | 462 ++++++++++++++++++ .../utils/thermostatValidateSchedule.test.js | 111 +++++ server/utils/thermostatSchedule.js | 281 +++++++++++ server/utils/thermostatValidateSchedule.js | 53 ++ 4 files changed, 907 insertions(+) create mode 100644 server/test/utils/thermostatSchedule.test.js create mode 100644 server/test/utils/thermostatValidateSchedule.test.js create mode 100644 server/utils/thermostatSchedule.js create mode 100644 server/utils/thermostatValidateSchedule.js diff --git a/server/test/utils/thermostatSchedule.test.js b/server/test/utils/thermostatSchedule.test.js new file mode 100644 index 0000000000..915e46d717 --- /dev/null +++ b/server/test/utils/thermostatSchedule.test.js @@ -0,0 +1,462 @@ +const { expect } = require('chai'); +const { + applySlotToDay, + mergeIntoSlots, + timeToMinutes, + minutesToTime, + parseEnd, + getCurrentDayAndMinutes, + findMatchingSlot, + findMatchingPreset, + DAY_MINUTES, +} = require('../../utils/thermostatSchedule'); + +describe('thermostatSchedule.applySlotToDay', () => { + it('should assign the correct day_of_week when no existing slots (bug fix)', () => { + const { fixedSlots } = applySlotToDay([], 3, 480, 600, 'comfort', 'k1', null); + expect(fixedSlots).to.have.lengthOf(1); + expect(fixedSlots[0].day_of_week).to.equal(3); + }); + + it('should assign day_of_week=0 correctly when targeting Monday', () => { + const { fixedSlots } = applySlotToDay([], 0, 480, 600, 'eco', 'k2', null); + expect(fixedSlots[0].day_of_week).to.equal(0); + }); + + it('should add slot with correct start/end times', () => { + const { fixedSlots } = applySlotToDay([], 1, 8 * 60, 12 * 60, 'comfort', 'k3', null); + expect(fixedSlots[0].start_time).to.equal('08:00'); + expect(fixedSlots[0].end_time).to.equal('12:00'); + expect(fixedSlots[0].preset).to.equal('comfort'); + }); + + it('should truncate an existing slot that overlaps at the start', () => { + const existing = [{ key: 'a', day_of_week: 2, start_time: '06:00', end_time: '10:00', preset: 'eco' }]; + const { fixedSlots } = applySlotToDay(existing, 2, 8 * 60, 12 * 60, 'comfort', 'k4', null); + const eco = fixedSlots.find((s) => s.preset === 'eco'); + expect(eco).to.not.equal(undefined); + expect(eco.end_time).to.equal('08:00'); + }); + + it('should truncate an existing slot that overlaps at the end', () => { + const existing = [{ key: 'b', day_of_week: 2, start_time: '10:00', end_time: '14:00', preset: 'away' }]; + const { fixedSlots } = applySlotToDay(existing, 2, 8 * 60, 12 * 60, 'comfort', 'k5', null); + const away = fixedSlots.find((s) => s.preset === 'away'); + expect(away).to.not.equal(undefined); + expect(away.start_time).to.equal('12:00'); + }); + + it('should split an existing slot that fully contains the new slot', () => { + const existing = [{ key: 'c', day_of_week: 4, start_time: '06:00', end_time: '20:00', preset: 'eco' }]; + const { fixedSlots } = applySlotToDay(existing, 4, 8 * 60, 12 * 60, 'comfort', 'k6', null); + const ecoParts = fixedSlots.filter((s) => s.preset === 'eco'); + expect(ecoParts).to.have.lengthOf(2); + expect(ecoParts[0].end_time).to.equal('08:00'); + expect(ecoParts[1].start_time).to.equal('12:00'); + }); + + it('should drop a slot fully covered by the new slot', () => { + const existing = [{ key: 'd', day_of_week: 5, start_time: '09:00', end_time: '11:00', preset: 'frost' }]; + const { fixedSlots } = applySlotToDay(existing, 5, 8 * 60, 12 * 60, 'comfort', 'k7', null); + const frost = fixedSlots.find((s) => s.preset === 'frost'); + expect(frost).to.equal(undefined); + }); + + it('should produce an overflow slot when newEnd exceeds DAY_MINUTES', () => { + const { fixedSlots, overflowSlot } = applySlotToDay([], 6, 23 * 60, 25 * 60, 'night', 'k8', null); + expect(fixedSlots[0].end_time).to.equal('00:00'); + expect(overflowSlot).to.not.equal(undefined); + expect(overflowSlot.start_time).to.eq('00:00'); + expect(overflowSlot.end_time).to.eq('01:00'); + }); + + it('should not produce overflow when newEnd is exactly DAY_MINUTES', () => { + const { overflowSlot } = applySlotToDay([], 0, 22 * 60, 24 * 60, 'night', 'k9', null); + expect(overflowSlot).to.equal(null); + }); +}); + +describe('thermostatSchedule.mergeIntoSlots', () => { + it('should replace slots for the target day only', () => { + const all = [ + { day_of_week: 0, start_time: '08:00', end_time: '12:00', preset: 'eco', key: 'x1' }, + { day_of_week: 1, start_time: '08:00', end_time: '12:00', preset: 'away', key: 'x2' }, + ]; + const newSlots = [{ day_of_week: 0, start_time: '09:00', end_time: '17:00', preset: 'comfort', key: 'x3' }]; + const result = mergeIntoSlots(all, 0, newSlots, null); + expect(result.filter((s) => s.day_of_week === 0)).to.have.lengthOf(1); + expect(result.filter((s) => s.day_of_week === 0)[0].key).to.equal('x3'); + expect(result.filter((s) => s.day_of_week === 1)).to.have.lengthOf(1); + }); + + it('should replace a next-day slot fully covered by the overflow', () => { + const all = [ + { day_of_week: 1, start_time: '00:00', end_time: '02:00', preset: 'frost', key: 'y1' }, + { day_of_week: 1, start_time: '06:00', end_time: '08:00', preset: 'eco', key: 'y2' }, + ]; + const fixed = [{ day_of_week: 0, start_time: '22:00', end_time: '00:00', preset: 'night', key: 'y3' }]; + const overflow = { start_time: '00:00', end_time: '02:00', preset: 'night', key: 'overflow-y3' }; + const result = mergeIntoSlots(all, 0, fixed, overflow); + const day1 = result.filter((s) => s.day_of_week === 1); + expect(day1).to.have.lengthOf(2); + expect(day1.find((s) => s.preset === 'night')).to.not.equal(undefined); + expect(day1.find((s) => s.key === 'y1')).to.equal(undefined); + expect(day1.find((s) => s.key === 'y2')).to.not.equal(undefined); + }); + + it('should trim a next-day slot the overflow only partially covers', () => { + // The morning slot starts at midnight but runs past the overflow: trimming it + // is right, dropping it would silently lose the rest of the morning. + const all = [{ day_of_week: 1, start_time: '00:00', end_time: '08:00', preset: 'eco', key: 'y1' }]; + const fixed = [{ day_of_week: 0, start_time: '22:00', end_time: '00:00', preset: 'night', key: 'y3' }]; + const overflow = { start_time: '00:00', end_time: '02:00', preset: 'night', key: 'overflow-y3' }; + + const result = mergeIntoSlots(all, 0, fixed, overflow); + + const day1 = result.filter((s) => s.day_of_week === 1); + const eco = day1.find((s) => s.key === 'y1'); + expect(eco).to.not.equal(undefined); + expect(eco.start_time).to.equal('02:00'); + expect(eco.end_time).to.equal('08:00'); + }); + + it('should leave a next-day slot starting after the overflow untouched', () => { + const all = [{ day_of_week: 1, start_time: '06:00', end_time: '08:00', preset: 'eco', key: 'y2' }]; + const fixed = [{ day_of_week: 0, start_time: '22:00', end_time: '00:00', preset: 'night', key: 'y3' }]; + const overflow = { start_time: '00:00', end_time: '02:00', preset: 'night', key: 'overflow-y3' }; + + const result = mergeIntoSlots(all, 0, fixed, overflow); + + const eco = result.filter((s) => s.day_of_week === 1).find((s) => s.key === 'y2'); + expect(eco).to.not.equal(undefined); + expect(eco.start_time).to.equal('06:00'); + }); + + it('should trim a next-day slot that runs to midnight', () => { + // end_time '00:00' means end of day, not minute zero. + const all = [{ day_of_week: 1, start_time: '00:00', end_time: '00:00', preset: 'eco', key: 'y4' }]; + const fixed = [{ day_of_week: 0, start_time: '22:00', end_time: '00:00', preset: 'night', key: 'y3' }]; + const overflow = { start_time: '00:00', end_time: '02:00', preset: 'night', key: 'overflow-y3' }; + + const result = mergeIntoSlots(all, 0, fixed, overflow); + + const eco = result.filter((s) => s.day_of_week === 1).find((s) => s.key === 'y4'); + expect(eco).to.not.equal(undefined); + expect(eco.start_time).to.equal('02:00'); + }); + + it('should treat an overflow ending at 00:00 as covering the whole next day', () => { + // Defensive: end_time '00:00' on the overflow itself means end of day. + const all = [{ day_of_week: 1, start_time: '06:00', end_time: '08:00', preset: 'eco', key: 'y5' }]; + const fixed = [{ day_of_week: 0, start_time: '22:00', end_time: '00:00', preset: 'night', key: 'y3' }]; + const overflow = { start_time: '00:00', end_time: '00:00', preset: 'night', key: 'overflow-y3' }; + + const result = mergeIntoSlots(all, 0, fixed, overflow); + + const day1 = result.filter((s) => s.day_of_week === 1); + expect(day1).to.have.lengthOf(1); + expect(day1[0].preset).to.equal('night'); + }); + + it('overflow on Sunday (day 6) should roll to day 0', () => { + const all = []; + const fixed = [{ day_of_week: 6, start_time: '23:00', end_time: '00:00', preset: 'night', key: 'z1' }]; + const overflow = { start_time: '00:00', end_time: '01:00', preset: 'night', key: 'overflow-z1' }; + const result = mergeIntoSlots(all, 6, fixed, overflow); + const day0 = result.filter((s) => s.day_of_week === 0); + expect(day0).to.have.lengthOf(1); + expect(day0[0].preset).to.equal('night'); + }); +}); + +describe('thermostatSchedule.timeToMinutes', () => { + it('should convert a HH:MM string', () => { + expect(timeToMinutes('08:30')).to.equal(510); + }); + + it('should treat a missing time as midnight', () => { + expect(timeToMinutes('')).to.equal(0); + expect(timeToMinutes(null)).to.equal(0); + expect(timeToMinutes(undefined)).to.equal(0); + }); + + it('should tolerate a time without minutes', () => { + expect(timeToMinutes('08')).to.equal(480); + }); +}); + +describe('thermostatSchedule.minutesToTime', () => { + it('should format minutes since midnight', () => { + expect(minutesToTime(510)).to.equal('08:30'); + expect(minutesToTime(0)).to.equal('00:00'); + }); + + it('should wrap values beyond a day', () => { + expect(minutesToTime(DAY_MINUTES + 60)).to.equal('01:00'); + }); + + it('should wrap negative values', () => { + expect(minutesToTime(-60)).to.equal('23:00'); + }); +}); + +describe('thermostatSchedule.parseEnd', () => { + it('should treat 00:00 as the end of the day', () => { + expect(parseEnd('00:00')).to.equal(DAY_MINUTES); + }); + + it('should parse a normal end time', () => { + expect(parseEnd('22:15')).to.equal(1335); + }); +}); + +describe('thermostatSchedule.getCurrentDayAndMinutes', () => { + // 2026-08-21 is a Friday: day 4 with Monday=0. + const reference = new Date('2026-08-21T06:30:00Z'); + + it('should read the clock in the given timezone', () => { + const { dayOfWeek, currentMinutes } = getCurrentDayAndMinutes(reference, 'Europe/Paris'); + + expect(dayOfWeek).to.equal(4); + // 06:30 UTC is 08:30 in Paris in August (UTC+2) + expect(currentMinutes).to.equal(8 * 60 + 30); + }); + + it('should give a different wall clock in another timezone', () => { + const { currentMinutes } = getCurrentDayAndMinutes(reference, 'UTC'); + + expect(currentMinutes).to.equal(6 * 60 + 30); + }); + + it('should roll over to the previous day when the timezone is behind', () => { + // 2026-08-21 00:30 UTC is still Thursday 20:30 in New York + const { dayOfWeek, currentMinutes } = getCurrentDayAndMinutes(new Date('2026-08-21T00:30:00Z'), 'America/New_York'); + + expect(dayOfWeek).to.equal(3); + expect(currentMinutes).to.equal(20 * 60 + 30); + }); + + it('should expose yesterday for overnight slots', () => { + expect(getCurrentDayAndMinutes(reference, 'Europe/Paris').yesterdayOfWeek).to.equal(3); + }); + + it('should wrap yesterday around the week on a Monday', () => { + // 2026-08-24 is a Monday + const { dayOfWeek, yesterdayOfWeek } = getCurrentDayAndMinutes(new Date('2026-08-24T09:00:00Z'), 'Europe/Paris'); + + expect(dayOfWeek).to.equal(0); + expect(yesterdayOfWeek).to.equal(6); + }); + + it('should render midnight as minute 0', () => { + const { currentMinutes } = getCurrentDayAndMinutes(new Date('2026-08-21T00:00:00Z'), 'UTC'); + + expect(currentMinutes).to.equal(0); + }); + + it('should fall back to the process timezone on an unknown zone', () => { + const { dayOfWeek, currentMinutes } = getCurrentDayAndMinutes(reference, 'Not/AZone'); + + expect(dayOfWeek).to.equal((reference.getDay() + 6) % 7); + expect(currentMinutes).to.equal(reference.getHours() * 60 + reference.getMinutes()); + }); + + it('should fall back to the process timezone when none is given', () => { + const { currentMinutes } = getCurrentDayAndMinutes(reference); + + expect(currentMinutes).to.equal(reference.getHours() * 60 + reference.getMinutes()); + }); + + it('should default to now when no date is given', () => { + expect(getCurrentDayAndMinutes()).to.have.all.keys('dayOfWeek', 'yesterdayOfWeek', 'currentMinutes'); + }); +}); + +describe('thermostatSchedule.findMatchingSlot', () => { + const slot = (start, end, preset) => ({ start_time: start, end_time: end, preset }); + + it('should match a normal slot of the day', () => { + const found = findMatchingSlot([slot('07:00', '09:00', 'comfort')], [], 8 * 60); + + expect(found.preset).to.equal('comfort'); + }); + + it('should exclude the end boundary', () => { + expect(findMatchingSlot([slot('07:00', '09:00', 'comfort')], [], 9 * 60)).to.equal(null); + }); + + it('should include the start boundary', () => { + expect(findMatchingSlot([slot('07:00', '09:00', 'comfort')], [], 7 * 60).preset).to.equal('comfort'); + }); + + it('should match an overnight slot on its starting day', () => { + const found = findMatchingSlot([slot('22:00', '06:00', 'night')], [], 23 * 60); + + expect(found.preset).to.equal('night'); + }); + + it("should match yesterday's overnight slot after midnight", () => { + const found = findMatchingSlot([], [slot('22:00', '06:00', 'night')], 2 * 60); + + expect(found.preset).to.equal('night'); + }); + + it("should not match yesterday's overnight slot once it ended", () => { + expect(findMatchingSlot([], [slot('22:00', '06:00', 'night')], 7 * 60)).to.equal(null); + }); + + it('should prefer a normal slot over an overnight one', () => { + const found = findMatchingSlot([slot('07:00', '09:00', 'comfort'), slot('22:00', '06:00', 'night')], [], 8 * 60); + + expect(found.preset).to.equal('comfort'); + }); + + it('should return null when nothing covers the time', () => { + expect(findMatchingSlot([slot('07:00', '09:00', 'comfort')], [], 12 * 60)).to.equal(null); + }); + + it('should handle a slot ending at midnight', () => { + expect(findMatchingSlot([slot('22:00', '00:00', 'night')], [], 23 * 60).preset).to.equal('night'); + }); +}); + +describe('thermostatSchedule.findMatchingPreset', () => { + it('should return the preset of the matching slot', () => { + const slots = [{ start_time: '07:00', end_time: '09:00', preset: 'comfort' }]; + + expect(findMatchingPreset(slots, [], 8 * 60)).to.equal('comfort'); + }); + + it('should return null when no slot matches', () => { + expect(findMatchingPreset([], [], 8 * 60)).to.equal(null); + }); +}); + +describe('thermostatSchedule.applySlotToDay - overlap handling', () => { + const slot = (key, start, end, preset = 'eco') => ({ + key, + day_of_week: 0, + start_time: start, + end_time: end, + preset, + }); + + it('should drop the slot being edited, identified by excludeKey', () => { + const existing = [slot('a', '07:00', '09:00'), slot('b', '12:00', '14:00')]; + + const { fixedSlots } = applySlotToDay(existing, 0, 12 * 60, 13 * 60, 'comfort', 'new', 'b'); + + expect(fixedSlots.filter((s) => s.key === 'b')).to.have.lengthOf(0); + expect(fixedSlots.filter((s) => s.key === 'a')).to.have.lengthOf(1); + }); + + it('should extend the immediate predecessor to close the gap', () => { + const existing = [slot('a', '06:00', '07:00')]; + + const { fixedSlots } = applySlotToDay(existing, 0, 8 * 60, 9 * 60, 'comfort', 'new', null); + + const predecessor = fixedSlots.find((s) => s.key === 'a'); + expect(predecessor.end_time).to.equal('08:00'); + }); + + it('should pick the closest predecessor when several end before the new slot', () => { + const existing = [slot('early', '04:00', '05:00'), slot('late', '06:00', '07:00')]; + + const { fixedSlots } = applySlotToDay(existing, 0, 8 * 60, 9 * 60, 'comfort', 'new', null); + + expect(fixedSlots.find((s) => s.key === 'late').end_time).to.equal('08:00'); + expect(fixedSlots.find((s) => s.key === 'early').end_time).to.equal('05:00'); + }); + + it('should leave a touching predecessor untouched', () => { + const existing = [slot('a', '06:00', '08:00')]; + + const { fixedSlots } = applySlotToDay(existing, 0, 8 * 60, 9 * 60, 'comfort', 'new', null); + + expect(fixedSlots.find((s) => s.key === 'a').end_time).to.equal('08:00'); + }); + + it('should keep a slot starting after the new one', () => { + const existing = [slot('a', '12:00', '14:00')]; + + const { fixedSlots } = applySlotToDay(existing, 0, 8 * 60, 9 * 60, 'comfort', 'new', null); + + expect(fixedSlots.find((s) => s.key === 'a').start_time).to.equal('12:00'); + }); + + it('should split a slot that fully contains the new one', () => { + const existing = [slot('a', '06:00', '20:00')]; + + const { fixedSlots } = applySlotToDay(existing, 0, 8 * 60, 9 * 60, 'comfort', 'new', null); + + const head = fixedSlots.find((s) => s.key === 'a'); + const tail = fixedSlots.find((s) => s.key === 'split-a'); + expect(head.end_time).to.equal('08:00'); + expect(tail.start_time).to.equal('09:00'); + expect(tail.end_time).to.equal('20:00'); + }); + + it('should trim a slot overlapping the start of the new one', () => { + const existing = [slot('a', '06:00', '08:30')]; + + const { fixedSlots } = applySlotToDay(existing, 0, 8 * 60, 9 * 60, 'comfort', 'new', null); + + expect(fixedSlots.find((s) => s.key === 'a').end_time).to.equal('08:00'); + }); + + it('should trim a slot overlapping the end of the new one', () => { + const existing = [slot('a', '08:30', '12:00')]; + + const { fixedSlots } = applySlotToDay(existing, 0, 8 * 60, 9 * 60, 'comfort', 'new', null); + + expect(fixedSlots.find((s) => s.key === 'a').start_time).to.equal('09:00'); + }); + + it('should drop a slot entirely covered by the new one', () => { + const existing = [slot('a', '08:15', '08:45')]; + + const { fixedSlots } = applySlotToDay(existing, 0, 8 * 60, 9 * 60, 'comfort', 'new', null); + + expect(fixedSlots.find((s) => s.key === 'a')).to.equal(undefined); + }); + + it('should treat an existing slot ending at 00:00 as ending at midnight', () => { + const existing = [slot('a', '22:00', '00:00')]; + + const { fixedSlots } = applySlotToDay(existing, 0, 23 * 60, 23 * 60 + 30, 'comfort', 'new', null); + + const head = fixedSlots.find((s) => s.key === 'a'); + expect(head.end_time).to.equal('23:00'); + expect(fixedSlots.find((s) => s.key === 'split-a').start_time).to.equal('23:30'); + }); + + it('should produce an overflow slot for a new slot crossing midnight', () => { + const { fixedSlots, overflowSlot } = applySlotToDay([], 0, 22 * 60, DAY_MINUTES + 6 * 60, 'night', 'new', null); + + expect(fixedSlots[0].end_time).to.equal('00:00'); + expect(overflowSlot.start_time).to.equal('00:00'); + expect(overflowSlot.end_time).to.equal('06:00'); + expect(overflowSlot.preset).to.equal('night'); + }); + + it('should not produce an overflow slot for a slot ending within the day', () => { + const { overflowSlot } = applySlotToDay([], 0, 8 * 60, 9 * 60, 'comfort', 'new', null); + + expect(overflowSlot).to.equal(null); + }); +}); + +describe('thermostatSchedule.applySlotToDay - end of day sorting', () => { + it('should treat several slots ending at 00:00 as ending at midnight when sorting', () => { + const existing = [ + { key: 'a', day_of_week: 0, start_time: '20:00', end_time: '00:00', preset: 'night' }, + { key: 'b', day_of_week: 0, start_time: '06:00', end_time: '00:00', preset: 'eco' }, + ]; + + const { fixedSlots } = applySlotToDay(existing, 0, 22 * 60, 23 * 60, 'comfort', 'new', null); + + // Both slots span the new one, so both are split around it + expect(fixedSlots.filter((s) => s.key === 'split-a')).to.have.lengthOf(1); + expect(fixedSlots.filter((s) => s.key === 'split-b')).to.have.lengthOf(1); + }); +}); diff --git a/server/test/utils/thermostatValidateSchedule.test.js b/server/test/utils/thermostatValidateSchedule.test.js new file mode 100644 index 0000000000..ceb4cda0b9 --- /dev/null +++ b/server/test/utils/thermostatValidateSchedule.test.js @@ -0,0 +1,111 @@ +const { expect } = require('chai'); + +const { validateSchedule } = require('../../utils/thermostatValidateSchedule'); + +const validSlot = { + day_of_week: 0, + start_time: '07:00', + end_time: '09:00', + preset: 'comfort', +}; + +const expectRejected = (payload, fragment) => { + let error = null; + try { + validateSchedule(payload); + } catch (e) { + error = e; + } + expect(error, `expected ${JSON.stringify(payload)} to be rejected`).to.not.equal(null); + if (fragment) { + expect(error.message).to.contain(fragment); + } +}; + +describe('thermostatValidateSchedule', () => { + it('should accept a schedule with valid slots', () => { + const value = validateSchedule({ name: 'Semaine', slots: [validSlot] }); + + expect(value.name).to.equal('Semaine'); + expect(value.slots).to.have.lengthOf(1); + }); + + it('should default an absent slot list to an empty array', () => { + expect(validateSchedule({ name: 'Semaine' }).slots).to.deep.equal([]); + }); + + it('should require a name', () => { + expectRejected({ slots: [] }, 'name'); + }); + + it('should reject an empty name', () => { + expectRejected({ name: '', slots: [] }, 'name'); + }); + + it('should reject a day outside 0-6', () => { + expectRejected({ name: 'x', slots: [{ ...validSlot, day_of_week: 7 }] }, 'day_of_week'); + expectRejected({ name: 'x', slots: [{ ...validSlot, day_of_week: -1 }] }, 'day_of_week'); + }); + + it('should reject a non-integer day', () => { + expectRejected({ name: 'x', slots: [{ ...validSlot, day_of_week: 1.5 }] }, 'day_of_week'); + }); + + it('should reject a malformed time', () => { + expectRejected({ name: 'x', slots: [{ ...validSlot, start_time: '7h' }] }, 'start_time'); + expectRejected({ name: 'x', slots: [{ ...validSlot, end_time: '25:00' }] }, 'end_time'); + expectRejected({ name: 'x', slots: [{ ...validSlot, end_time: '09:70' }] }, 'end_time'); + }); + + it('should accept the boundary times', () => { + const value = validateSchedule({ + name: 'x', + slots: [{ ...validSlot, start_time: '00:00', end_time: '23:59' }], + }); + + expect(value.slots).to.have.lengthOf(1); + }); + + it('should reject an unknown preset', () => { + expectRejected({ name: 'x', slots: [{ ...validSlot, preset: 'party' }] }, 'preset'); + }); + + it('should accept every known preset', () => { + ['off', 'frost', 'away', 'eco', 'night', 'comfort'].forEach((preset) => { + expect(validateSchedule({ name: 'x', slots: [{ ...validSlot, preset }] }).slots[0].preset).to.equal(preset); + }); + }); + + it('should accept the row metadata a slot read from the database carries', () => { + // The editor sends back the slots it was given, ids and timestamps included. + // Rejecting them would make every edit of an existing schedule fail. + const stored = { + ...validSlot, + id: '5bbaaea4-2ad6-4f3e-9bbc-819b9d310309', + schedule_id: 'a810b8db-6d04-4697-bed3-c4b72c996279', + created_at: '2026-08-23T10:00:00.000Z', + updated_at: '2026-08-23T10:00:00.000Z', + }; + + expect(validateSchedule({ name: 'Absent', slots: [stored] }).slots).to.have.lengthOf(1); + }); + + it('should still reject an invalid slot that carries row metadata', () => { + const stored = { ...validSlot, id: '5bbaaea4-2ad6-4f3e-9bbc-819b9d310309' }; + + expectRejected({ name: 'x', slots: [{ ...stored, day_of_week: 9 }] }, 'day_of_week'); + expectRejected({ name: 'x', slots: [{ ...stored, preset: 'party' }] }, 'preset'); + }); + + it('should reject a missing slot field', () => { + expectRejected({ name: 'x', slots: [{ day_of_week: 0, start_time: '07:00', end_time: '09:00' }] }, 'preset'); + }); + + it('should reject a payload that is not an object', () => { + expectRejected('nonsense'); + }); + + it('should tolerate an undefined payload', () => { + expectRejected(undefined, 'name'); + }); +}); diff --git a/server/utils/thermostatSchedule.js b/server/utils/thermostatSchedule.js new file mode 100644 index 0000000000..29cb0e8306 --- /dev/null +++ b/server/utils/thermostatSchedule.js @@ -0,0 +1,281 @@ +const DAY_MINUTES = 24 * 60; // 1440 + +/** + * @description Convert a "HH:MM" time string to minutes from midnight. + * @param {string} time - Time string in HH:MM format. + * @returns {number} Minutes from midnight. + * @example + * timeToMinutes('08:30'); // 510 + */ +const timeToMinutes = (time) => { + if (!time) { + return 0; + } + const [h, m] = time.split(':').map(Number); + return h * 60 + (m || 0); +}; + +/** + * @description Convert minutes from midnight to a "HH:MM" time string. + * Values exceeding DAY_MINUTES wrap around. + * @param {number} mins - Minutes from midnight (may exceed 1440). + * @returns {string} Time string in HH:MM format. + * @example + * minutesToTime(510); // '08:30' + */ +const minutesToTime = (mins) => { + const m = ((mins % DAY_MINUTES) + DAY_MINUTES) % DAY_MINUTES; + return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`; +}; + +/** + * @description Apply a new or edited slot into a day's existing slot list. + * Overlapping slots are trimmed/dropped and the predecessor is extended to close gaps. + * A newEnd exceeding DAY_MINUTES generates an overflow slot for the next day. + * @param {Array} existingDaySlots - Current slots for this day. + * @param {number} dayOfWeek - Target day (0=Monday … 6=Sunday). + * @param {number} newStart - Slot start in minutes from midnight. + * @param {number} newEnd - Slot end in minutes (may exceed 1440 for overnight). + * @param {string} newPreset - Preset name for the new slot. + * @param {string|number} newKey - Unique key for the new slot. + * @param {string|number|null} excludeKey - Key of the slot being replaced (edit), or null. + * @returns {{ fixedSlots: Array, overflowSlot: object|null }} Adjusted slots and optional overflow. + * @example + * applySlotToDay([], 3, 480, 600, 'comfort', 'k1', null); + */ +const applySlotToDay = (existingDaySlots, dayOfWeek, newStart, newEnd, newPreset, newKey, excludeKey) => { + const clampedEnd = Math.min(newEnd, DAY_MINUTES); + const overflowMins = newEnd > DAY_MINUTES ? newEnd - DAY_MINUTES : 0; + + const slots = excludeKey ? existingDaySlots.filter((s) => s.key !== excludeKey) : existingDaySlots; + + const sortedByEnd = slots.slice().sort((a, b) => { + const aEnd = timeToMinutes(a.end_time) || DAY_MINUTES; + const bEnd = timeToMinutes(b.end_time) || DAY_MINUTES; + return aEnd - bEnd; + }); + + let predecessorKey = null; + let predecessorEnd = -1; + sortedByEnd.forEach((s) => { + const sEnd = timeToMinutes(s.end_time) || DAY_MINUTES; + if (sEnd <= newStart && sEnd > predecessorEnd) { + predecessorEnd = sEnd; + predecessorKey = s.key; + } + }); + + const adjusted = []; + slots.forEach((s) => { + const sStart = timeToMinutes(s.start_time); + const sEndRaw = timeToMinutes(s.end_time); + const sEnd = sEndRaw === 0 ? DAY_MINUTES : sEndRaw; + + if (sEnd <= newStart || sStart >= clampedEnd) { + if (s.key === predecessorKey && sEnd < newStart) { + adjusted.push({ ...s, end_time: minutesToTime(newStart) }); + } else { + adjusted.push(s); + } + } else if (sStart < newStart && sEnd > clampedEnd) { + adjusted.push({ ...s, end_time: minutesToTime(newStart) }); + adjusted.push({ ...s, start_time: minutesToTime(clampedEnd), key: `split-${s.key}` }); + } else if (sStart < newStart) { + adjusted.push({ ...s, end_time: minutesToTime(newStart) }); + } else if (sEnd > clampedEnd) { + adjusted.push({ ...s, start_time: minutesToTime(clampedEnd) }); + } + }); + + adjusted.push({ + day_of_week: dayOfWeek, + start_time: minutesToTime(newStart), + end_time: minutesToTime(clampedEnd), + preset: newPreset, + key: newKey, + }); + + const overflowSlot = + overflowMins > 0 + ? { + start_time: '00:00', + end_time: minutesToTime(overflowMins), + preset: newPreset, + key: `overflow-${newKey}`, + } + : null; + + return { fixedSlots: adjusted, overflowSlot }; +}; + +/** + * @description Merge fixed day slots and optional overflow into the global slots array. + * The overflow covers [00:00, overflowEnd] on the next day: slots it overlaps are + * trimmed to start at its end, and only those fully covered are dropped. Deleting + * every slot that starts at 00:00 would silently lose a morning slot that merely + * began at midnight. + * @param {Array} allSlots - All slots across all days. + * @param {number} dayOfWeek - The day whose slots were rebuilt. + * @param {Array} taggedFixed - Rebuilt slots for dayOfWeek (already tagged with day_of_week). + * @param {object|null} overflowSlot - Overflow slot for the next day, or null. + * @returns {Array} Updated full slots array. + * @example + * // Replace all slots for day 0, no overflow: + * mergeIntoSlots(allSlots, 0, fixedSlots, null); + */ +const mergeIntoSlots = (allSlots, dayOfWeek, taggedFixed, overflowSlot) => { + if (!overflowSlot) { + return [...allSlots.filter((s) => s.day_of_week !== dayOfWeek), ...taggedFixed]; + } + const nextDay = (dayOfWeek + 1) % 7; + const overflowEnd = timeToMinutes(overflowSlot.end_time) || DAY_MINUTES; + const nextDayKept = []; + allSlots + .filter((s) => s.day_of_week === nextDay) + .forEach((s) => { + const sStart = timeToMinutes(s.start_time); + const sEndRaw = timeToMinutes(s.end_time); + const sEnd = sEndRaw === 0 ? DAY_MINUTES : sEndRaw; + if (sStart >= overflowEnd) { + // Starts after the overflow: untouched. + nextDayKept.push(s); + return; + } + if (sEnd > overflowEnd) { + // Partially covered: keep the tail rather than dropping the whole slot. + nextDayKept.push({ ...s, start_time: minutesToTime(overflowEnd) }); + } + // Fully covered by the overflow: dropped. + }); + const otherDays = allSlots.filter((s) => s.day_of_week !== dayOfWeek && s.day_of_week !== nextDay); + return [...otherDays, ...taggedFixed, ...nextDayKept, { ...overflowSlot, day_of_week: nextDay }]; +}; + +/** + * @description Parse an end time string, treating 00:00 as end of day (1440 minutes). + * @param {string} timeStr - Time string in HH:MM format. + * @returns {number} Minutes since midnight, 1440 if 00:00. + * @example + * parseEnd('00:00'); // 1440 + */ +const parseEnd = (timeStr) => { + const v = timeToMinutes(timeStr); + return v === 0 ? DAY_MINUTES : v; +}; + +const WEEKDAY_INDEX = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 }; + +/** + * @description Return the current day-of-week (Monday=0) and minutes since midnight, + * read in the given timezone. Official Gladys images run in UTC, so relying on the + * process timezone would shift every schedule slot by the local UTC offset. + * Falls back to the process timezone when the zone name is unknown. + * @param {Date} [now] - Reference date, defaults to the current time. + * @param {string} [timezone] - IANA timezone name, e.g. 'Europe/Paris'. + * @returns {{ dayOfWeek: number, yesterdayOfWeek: number, currentMinutes: number }} Current position in the week. + * @example + * getCurrentDayAndMinutes(new Date('2026-08-21T08:30:00Z'), 'Europe/Paris'); + */ +const getCurrentDayAndMinutes = (now = new Date(), timezone = null) => { + let weekday = null; + let hours = null; + let minutes = null; + if (timezone) { + try { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + weekday: 'short', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(now); + const get = (type) => parts.find((part) => part.type === type).value; + const parsedWeekday = WEEKDAY_INDEX[get('weekday')]; + // Intl renders midnight as hour 24 in some ICU versions + const parsedHours = parseInt(get('hour'), 10) % 24; + const parsedMinutes = parseInt(get('minute'), 10); + weekday = parsedWeekday; + hours = parsedHours; + minutes = parsedMinutes; + } catch (e) { + // Unknown timezone: fall back to the process timezone below + } + } + if (weekday === null) { + weekday = (now.getDay() + 6) % 7; // Monday=0 ... Sunday=6 + hours = now.getHours(); + minutes = now.getMinutes(); + } + return { + dayOfWeek: weekday, + yesterdayOfWeek: (weekday + 6) % 7, + currentMinutes: hours * 60 + minutes, + }; +}; + +/** + * @description Find the slot covering the current time, handling slots that cross midnight. + * Checked in order: today's normal slots, today's overnight slots (start day part), + * then yesterday's overnight slots (which spill into today). + * @param {Array} todaySlots - Slots for the current day. + * @param {Array} yesterdaySlots - Slots for the previous day. + * @param {number} currentMinutes - Current time in minutes since midnight. + * @returns {object|null} The matching slot, or null when no slot covers this time. + * @example + * findMatchingSlot(todaySlots, yesterdaySlots, 480); + */ +const findMatchingSlot = (todaySlots, yesterdaySlots, currentMinutes) => { + // Today's normal slots (start < end, same day) + const matchedToday = todaySlots.find((slot) => { + const slotStart = timeToMinutes(slot.start_time); + const slotEnd = parseEnd(slot.end_time); + return slotEnd > slotStart && currentMinutes >= slotStart && currentMinutes < slotEnd; + }); + if (matchedToday) { + return matchedToday; + } + + // Today's overnight slots (end < start): covers start → 23:59 on the start day + const matchedOvernightStart = todaySlots.find((slot) => { + const slotStart = timeToMinutes(slot.start_time); + const slotEnd = timeToMinutes(slot.end_time); + return slotEnd < slotStart && currentMinutes >= slotStart; + }); + if (matchedOvernightStart) { + return matchedOvernightStart; + } + + // Yesterday's overnight slots: covers 00:00 → end on the following day + const matchedOvernightEnd = yesterdaySlots.find((slot) => { + const slotStart = timeToMinutes(slot.start_time); + const slotEnd = timeToMinutes(slot.end_time); + return slotEnd < slotStart && currentMinutes < slotEnd; + }); + return matchedOvernightEnd || null; +}; + +/** + * @description Find the preset active at the current time in a list of slots. + * @param {Array} todaySlots - Slots for the current day. + * @param {Array} yesterdaySlots - Slots for the previous day. + * @param {number} currentMinutes - Current time in minutes since midnight. + * @returns {string|null} Matched preset, or null when no slot covers this time. + * @example + * findMatchingPreset(todaySlots, yesterdaySlots, 480); // 'comfort' + */ +const findMatchingPreset = (todaySlots, yesterdaySlots, currentMinutes) => { + const slot = findMatchingSlot(todaySlots, yesterdaySlots, currentMinutes); + return slot ? slot.preset : null; +}; + +module.exports = { + applySlotToDay, + mergeIntoSlots, + timeToMinutes, + minutesToTime, + parseEnd, + getCurrentDayAndMinutes, + findMatchingSlot, + findMatchingPreset, + DAY_MINUTES, +}; diff --git a/server/utils/thermostatValidateSchedule.js b/server/utils/thermostatValidateSchedule.js new file mode 100644 index 0000000000..dd53fec863 --- /dev/null +++ b/server/utils/thermostatValidateSchedule.js @@ -0,0 +1,53 @@ +const Joi = require('joi'); +const { PRESETS } = require('./thermostatConstants'); + +const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/; + +const slotSchema = Joi.object({ + day_of_week: Joi.number() + .integer() + .min(0) + .max(6) + .required(), + start_time: Joi.string() + .regex(TIME_PATTERN) + .required(), + end_time: Joi.string() + .regex(TIME_PATTERN) + .required(), + preset: Joi.string() + .valid(...PRESETS) + .required(), +}) + // A slot read back from the database carries its row metadata, and the editor + // sends the slots it was given. Those columns are ignored on write — the slots + // are replaced wholesale — so accept them rather than rejecting the payload. + .unknown(true); + +const scheduleSchema = Joi.object({ + name: Joi.string() + .min(1) + .required(), + slots: Joi.array() + .items(slotSchema) + .default([]), +}).unknown(true); + +/** + * @description Validate a schedule payload before it reaches the database. + * Invalid days, times or presets would be stored and then silently match + * nothing at regulation time, so they are rejected up front. + * @param {object} scheduleData - Schedule payload: { name, slots }. + * @returns {{ name: string, slots: Array }} The validated payload. + * @example + * validateSchedule({ name: 'Semaine', slots: [] }); + */ +function validateSchedule(scheduleData) { + const { error, value } = scheduleSchema.validate(scheduleData || {}); + if (error) { + throw new Error(`Invalid thermostat schedule: ${error.message}`); + } + return value; +} + +module.exports = { validateSchedule, slotSchema, scheduleSchema, TIME_PATTERN }; From c5c75235bbc860009a94b3d6d6367188f34f54d0 Mon Sep 17 00:00:00 2001 From: William Deren Date: Sun, 23 Aug 2026 11:13:01 +0200 Subject: [PATCH 03/29] feat(thermostat): add the thermostat integration service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gladys can already read and command real thermostats. What it cannot do is be one: turn a plain temperature sensor plus a plain switch — a relay, a smart plug, a boiler contact — into a regulated heating zone. That is the most common French setup, and today it takes a hand-written scene per threshold, with no schedule and no anti-short-cycling. The service creates one virtual device per heating zone, carrying a single thermostat/target-temperature feature so it is indistinguishable from a Netatmo one to scenes, MQTT and the device pages. Everything the control loop needs is a THERMOSTAT_* device param; createDevice accepts only those and one setpoint feature, rather than forwarding whatever the client sent. The loop ticks every minute: - a configured window sensor reading open cuts the switch and stops there, and a NEW_STATE listener applies the same cut immediately rather than waiting for the tick; - a manual override holds the setpoint until its timer expires; - otherwise the active schedule's slot decides the preset, falling back to the last preset when no slot covers now; - the switch is actuated only when its state differs from the computed one, by hysteresis or by TPI. TPI is heating-only: a cooling compressor cannot be pulsed, so cooling always uses hysteresis. Its position in the cycle is offset by a hash of the feature selector, otherwise every thermostat sharing a cycle time switches on at the same wall-clock minute and the loads stack up. setValue is the path scenes take. Persisting the value alone would not survive — the next pass re-applies the scheduled preset — so an external write becomes a manual override, exactly like turning the dial on the widget. The setpoint route only accepts a target-temperature feature owned by this service: without that check, any authenticated household member could persist a value on a lock or a cover by naming its selector. Runtime variables are removed when the device is deleted, so a device recreated with the same selector does not inherit a stale preset. Co-Authored-By: Claude Opus 5 --- server/services/index.js | 1 + .../thermostat/api/thermostat.controller.js | 206 ++++++ server/services/thermostat/index.js | 79 +++ server/services/thermostat/lib/index.js | 39 ++ .../lib/thermostat.applySchedules.js | 432 ++++++++++++ .../thermostat/lib/thermostat.createDevice.js | 72 ++ .../lib/thermostat.createSchedule.js | 48 ++ .../lib/thermostat.deleteSchedule.js | 21 + .../thermostat/lib/thermostat.deviceConfig.js | 103 +++ .../thermostat/lib/thermostat.getDevices.js | 23 + .../thermostat/lib/thermostat.getSchedules.js | 21 + .../thermostat/lib/thermostat.onWindowOpen.js | 111 ++++ .../thermostat/lib/thermostat.postDelete.js | 38 ++ .../thermostat/lib/thermostat.setValue.js | 46 ++ .../thermostat/lib/thermostat.setVariable.js | 121 ++++ .../lib/thermostat.updateSchedule.js | 61 ++ server/services/thermostat/package.json | 16 + .../api/thermostat.controller.test.js | 350 ++++++++++ server/test/services/thermostat/index.test.js | 159 +++++ .../services/thermostat/lib/index.test.js | 39 ++ .../thermostat.applySchedules.helpers.test.js | 195 ++++++ ...thermostat.applySchedules.helpers2.test.js | 262 ++++++++ .../lib/thermostat.applySchedules.test.js | 345 ++++++++++ .../lib/thermostat.deviceConfig.test.js | 136 ++++ .../thermostat/lib/thermostat.devices.test.js | 260 ++++++++ .../lib/thermostat.onWindowOpen.test.js | 517 +++++++++++++++ .../lib/thermostat.regulateDevice.test.js | 616 ++++++++++++++++++ .../lib/thermostat.schedules.test.js | 251 +++++++ .../lib/thermostat.setValue.test.js | 135 ++++ .../lib/thermostat.setVariable.test.js | 177 +++++ 30 files changed, 4880 insertions(+) create mode 100644 server/services/thermostat/api/thermostat.controller.js create mode 100644 server/services/thermostat/index.js create mode 100644 server/services/thermostat/lib/index.js create mode 100644 server/services/thermostat/lib/thermostat.applySchedules.js create mode 100644 server/services/thermostat/lib/thermostat.createDevice.js create mode 100644 server/services/thermostat/lib/thermostat.createSchedule.js create mode 100644 server/services/thermostat/lib/thermostat.deleteSchedule.js create mode 100644 server/services/thermostat/lib/thermostat.deviceConfig.js create mode 100644 server/services/thermostat/lib/thermostat.getDevices.js create mode 100644 server/services/thermostat/lib/thermostat.getSchedules.js create mode 100644 server/services/thermostat/lib/thermostat.onWindowOpen.js create mode 100644 server/services/thermostat/lib/thermostat.postDelete.js create mode 100644 server/services/thermostat/lib/thermostat.setValue.js create mode 100644 server/services/thermostat/lib/thermostat.setVariable.js create mode 100644 server/services/thermostat/lib/thermostat.updateSchedule.js create mode 100644 server/services/thermostat/package.json create mode 100644 server/test/services/thermostat/api/thermostat.controller.test.js create mode 100644 server/test/services/thermostat/index.test.js create mode 100644 server/test/services/thermostat/lib/index.test.js create mode 100644 server/test/services/thermostat/lib/thermostat.applySchedules.helpers.test.js create mode 100644 server/test/services/thermostat/lib/thermostat.applySchedules.helpers2.test.js create mode 100644 server/test/services/thermostat/lib/thermostat.applySchedules.test.js create mode 100644 server/test/services/thermostat/lib/thermostat.deviceConfig.test.js create mode 100644 server/test/services/thermostat/lib/thermostat.devices.test.js create mode 100644 server/test/services/thermostat/lib/thermostat.onWindowOpen.test.js create mode 100644 server/test/services/thermostat/lib/thermostat.regulateDevice.test.js create mode 100644 server/test/services/thermostat/lib/thermostat.schedules.test.js create mode 100644 server/test/services/thermostat/lib/thermostat.setValue.test.js create mode 100644 server/test/services/thermostat/lib/thermostat.setVariable.test.js diff --git a/server/services/index.js b/server/services/index.js index 31f9d46635..ebd845658a 100644 --- a/server/services/index.js +++ b/server/services/index.js @@ -36,3 +36,4 @@ module.exports['google-cast'] = require('./google-cast'); module.exports.airplay = require('./airplay'); module.exports['free-mobile'] = require('./free-mobile'); module.exports.mcp = require('./mcp'); +module.exports.thermostat = require('./thermostat'); diff --git a/server/services/thermostat/api/thermostat.controller.js b/server/services/thermostat/api/thermostat.controller.js new file mode 100644 index 0000000000..f220c306ae --- /dev/null +++ b/server/services/thermostat/api/thermostat.controller.js @@ -0,0 +1,206 @@ +const asyncMiddleware = require('../../../api/middlewares/asyncMiddleware'); +const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../utils/constants'); +const { isRuntimeVariableKey } = require('../lib/thermostat.setVariable'); + +module.exports = function ThermostatController(thermostatHandler) { + /** + * @api {get} /api/v1/service/thermostat/device Get thermostat devices + * @apiName getDevices + * @apiGroup Thermostat + */ + async function getDevices(req, res) { + const devices = await thermostatHandler.getDevices({ + search: req.query.search, + order_dir: req.query.order_dir, + }); + res.json(devices); + } + + /** + * @api {post} /api/v1/service/thermostat/device Create thermostat device + * @apiName createDevice + * @apiGroup Thermostat + */ + async function createDevice(req, res) { + const device = await thermostatHandler.createDevice(req.body); + res.json(device); + } + + /** + * @api {get} /api/v1/service/thermostat/schedule Get all schedules + * @apiName getSchedules + * @apiGroup Thermostat + */ + async function getSchedules(req, res) { + const schedules = await thermostatHandler.getSchedules(); + res.json(schedules); + } + + /** + * @api {post} /api/v1/service/thermostat/schedule Create a schedule + * @apiName createSchedule + * @apiGroup Thermostat + */ + async function createSchedule(req, res) { + const schedule = await thermostatHandler.createSchedule(req.body); + res.json(schedule); + } + + /** + * @api {patch} /api/v1/service/thermostat/schedule/:selector Update a schedule + * @apiName updateSchedule + * @apiGroup Thermostat + */ + async function updateSchedule(req, res) { + const schedule = await thermostatHandler.updateSchedule(req.params.selector, req.body); + res.json(schedule); + } + + /** + * @api {delete} /api/v1/service/thermostat/schedule/:selector Delete a schedule + * @apiName deleteSchedule + * @apiGroup Thermostat + */ + async function deleteSchedule(req, res) { + await thermostatHandler.deleteSchedule(req.params.selector); + res.json({ success: true }); + } + + /** + * @api {post} /api/v1/service/thermostat/setpoint/:feature_selector Set thermostat setpoint + * @apiName setSetpoint + * @apiGroup Thermostat + */ + async function setSetpoint(req, res) { + const featureSelector = req.params.feature_selector; + const value = Number(req.body.value); + if (!Number.isFinite(value)) { + res.status(400).json({ error: 'INVALID_VALUE' }); + return; + } + // Only a thermostat setpoint owned by this service may be written here. + // Without this check any authenticated user could persist a value on a lock, + // a cover or a light just by naming its selector. + const devices = await thermostatHandler.getDevices({}); + let device = null; + let deviceFeature = null; + devices.some((candidate) => { + const found = (candidate.features || []).find( + (feature) => + feature.selector === featureSelector && + feature.category === DEVICE_FEATURE_CATEGORIES.THERMOSTAT && + feature.type === DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE, + ); + if (found) { + device = candidate; + deviceFeature = found; + return true; + } + return false; + }); + if (!deviceFeature) { + res.status(404).json({ error: 'FEATURE_NOT_FOUND' }); + return; + } + // Go through setValue so the widget, the API and scenes share one path. + await thermostatHandler.setValue(device, deviceFeature, value); + res.json({ success: true, value }); + } + + /** + * @api {post} /api/v1/service/thermostat/state/:variable_key Set a thermostat runtime variable + * @apiName setVariable + * @apiGroup Thermostat + * @apiDescription Sets a THERMOSTAT_* runtime variable (preset, manual override), + * broadcasts the matching websocket message and schedules an immediate regulation + * pass. The path is "state" rather than "variable" because the core already mounts + * `/api/v1/service/:service_name/variable/:variable_key`, which would shadow it. + * The configuration is not writable here: it lives on the device. + */ + async function setVariable(req, res) { + if (!isRuntimeVariableKey(req.params.variable_key)) { + res.status(400).json({ error: 'INVALID_VARIABLE_KEY' }); + return; + } + const variable = await thermostatHandler.setVariable(req.params.variable_key, req.body.value); + res.json(variable); + } + + /** + * @api {get} /api/v1/service/thermostat/state/:variable_key Get a thermostat runtime variable + * @apiName getVariable + * @apiGroup Thermostat + * @apiDescription Reads a THERMOSTAT_* runtime variable in this service's scope, + * so the widget reads exactly the rows the regulation loop writes. + */ + async function getVariable(req, res) { + if (!isRuntimeVariableKey(req.params.variable_key)) { + res.status(400).json({ error: 'INVALID_VARIABLE_KEY' }); + return; + } + const value = await thermostatHandler.getVariable(req.params.variable_key); + if (value === null || value === undefined) { + res.status(404).json({ error: 'VARIABLE_NOT_FOUND' }); + return; + } + res.json({ value }); + } + + /** + * @api {post} /api/v1/service/thermostat/apply-schedules Trigger a regulation pass + * @apiName applySchedules + * @apiGroup Thermostat + * @apiDescription Runs a debounced regulation pass so a configuration change + * takes effect immediately instead of on the next minute tick. + */ + async function applySchedules(req, res) { + // The caller just changed a device's configuration: tell the open dashboards + // to reload it, then regulate on it without waiting for the next minute tick. + thermostatHandler.broadcastConfigUpdated(); + thermostatHandler.triggerApplySchedules(); + res.json({ success: true }); + } + + return { + 'post /api/v1/service/thermostat/apply-schedules': { + authenticated: true, + controller: asyncMiddleware(applySchedules), + }, + 'get /api/v1/service/thermostat/device': { + authenticated: true, + controller: asyncMiddleware(getDevices), + }, + 'post /api/v1/service/thermostat/device': { + authenticated: true, + controller: asyncMiddleware(createDevice), + }, + 'get /api/v1/service/thermostat/schedule': { + authenticated: true, + controller: asyncMiddleware(getSchedules), + }, + 'post /api/v1/service/thermostat/schedule': { + authenticated: true, + controller: asyncMiddleware(createSchedule), + }, + 'patch /api/v1/service/thermostat/schedule/:selector': { + authenticated: true, + controller: asyncMiddleware(updateSchedule), + }, + 'delete /api/v1/service/thermostat/schedule/:selector': { + authenticated: true, + controller: asyncMiddleware(deleteSchedule), + }, + 'post /api/v1/service/thermostat/setpoint/:feature_selector': { + authenticated: true, + controller: asyncMiddleware(setSetpoint), + }, + 'post /api/v1/service/thermostat/state/:variable_key': { + authenticated: true, + controller: asyncMiddleware(setVariable), + }, + 'get /api/v1/service/thermostat/state/:variable_key': { + authenticated: true, + controller: asyncMiddleware(getVariable), + }, + }; +}; diff --git a/server/services/thermostat/index.js b/server/services/thermostat/index.js new file mode 100644 index 0000000000..4867df8c85 --- /dev/null +++ b/server/services/thermostat/index.js @@ -0,0 +1,79 @@ +const logger = require('../../utils/logger'); +const { EVENTS } = require('../../utils/constants'); +const ThermostatHandler = require('./lib'); +const ThermostatController = require('./api/thermostat.controller'); + +module.exports = function ThermostatService(gladys, serviceId) { + const thermostatHandler = new ThermostatHandler(gladys, serviceId); + let scheduleInterval = null; + let newStateListener = null; + + /** + * @description Clear the interval, the event listener and the debounce timer. + * Shared by stop() and by start(), which must be idempotent. + * @returns {undefined} + * @example + * clearHandles(); + */ + function clearHandles() { + if (scheduleInterval) { + clearInterval(scheduleInterval); + scheduleInterval = null; + } + if (newStateListener) { + gladys.event.removeListener(EVENTS.DEVICE.NEW_STATE, newStateListener); + newStateListener = null; + } + if (thermostatHandler.applyTimer) { + clearTimeout(thermostatHandler.applyTimer); + thermostatHandler.applyTimer = null; + } + } + + /** + * @public + * @description This function starts the Thermostat service. + * @example + * gladys.services.thermostat.start(); + */ + async function start() { + logger.info('Starting thermostat service'); + // Idempotent: a second start() without a stop() would otherwise leave the + // previous interval and listener running, and the house would get two + // regulation loops actuating the same heaters. + clearHandles(); + // Apply schedules every minute + scheduleInterval = setInterval(async () => { + await thermostatHandler.applySchedules(); + }, 60 * 1000); + // React immediately when a device feature changes (e.g. window opens) + newStateListener = (event) => thermostatHandler.onDeviceNewState(event); + gladys.event.on(EVENTS.DEVICE.NEW_STATE, newStateListener); + // Run once immediately on start (non-blocking — errors must not prevent Gladys startup) + (async () => { + try { + await thermostatHandler.applySchedules(); + } catch (e) { + logger.warn(`Thermostat applySchedules startup error: ${e.message}`); + } + })(); + } + + /** + * @public + * @description This function stops the Thermostat service. + * @example + * gladys.services.thermostat.stop(); + */ + async function stop() { + logger.info('Stopping thermostat service'); + clearHandles(); + } + + return Object.freeze({ + start, + stop, + device: thermostatHandler, + controllers: ThermostatController(thermostatHandler), + }); +}; diff --git a/server/services/thermostat/lib/index.js b/server/services/thermostat/lib/index.js new file mode 100644 index 0000000000..64e38f3682 --- /dev/null +++ b/server/services/thermostat/lib/index.js @@ -0,0 +1,39 @@ +const { createDevice } = require('./thermostat.createDevice'); +const { getDevices } = require('./thermostat.getDevices'); +const { getSchedules } = require('./thermostat.getSchedules'); +const { createSchedule } = require('./thermostat.createSchedule'); +const { updateSchedule } = require('./thermostat.updateSchedule'); +const { deleteSchedule } = require('./thermostat.deleteSchedule'); +const { applySchedules } = require('./thermostat.applySchedules'); +const { onDeviceNewState, getWindowSelectors, invalidateWindowCache } = require('./thermostat.onWindowOpen'); +const { setValue } = require('./thermostat.setValue'); +const { postDelete } = require('./thermostat.postDelete'); +const { setVariable, getVariable, broadcastConfigUpdated, triggerApplySchedules } = require('./thermostat.setVariable'); + +const ThermostatHandler = function ThermostatHandler(gladys, serviceId) { + this.gladys = gladys; + this.serviceId = serviceId; + this.applyTimer = null; + // Window-sensor selectors, rebuilt lazily and dropped whenever a thermostat + // device is created or deleted. + this.windowSelectorsCache = null; +}; + +ThermostatHandler.prototype.createDevice = createDevice; +ThermostatHandler.prototype.getDevices = getDevices; +ThermostatHandler.prototype.getSchedules = getSchedules; +ThermostatHandler.prototype.createSchedule = createSchedule; +ThermostatHandler.prototype.updateSchedule = updateSchedule; +ThermostatHandler.prototype.deleteSchedule = deleteSchedule; +ThermostatHandler.prototype.applySchedules = applySchedules; +ThermostatHandler.prototype.onDeviceNewState = onDeviceNewState; +ThermostatHandler.prototype.getWindowSelectors = getWindowSelectors; +ThermostatHandler.prototype.invalidateWindowCache = invalidateWindowCache; +ThermostatHandler.prototype.setValue = setValue; +ThermostatHandler.prototype.postDelete = postDelete; +ThermostatHandler.prototype.setVariable = setVariable; +ThermostatHandler.prototype.getVariable = getVariable; +ThermostatHandler.prototype.broadcastConfigUpdated = broadcastConfigUpdated; +ThermostatHandler.prototype.triggerApplySchedules = triggerApplySchedules; + +module.exports = ThermostatHandler; diff --git a/server/services/thermostat/lib/thermostat.applySchedules.js b/server/services/thermostat/lib/thermostat.applySchedules.js new file mode 100644 index 0000000000..aa40f4c46a --- /dev/null +++ b/server/services/thermostat/lib/thermostat.applySchedules.js @@ -0,0 +1,432 @@ +const db = require('../../../models'); +const logger = require('../../../utils/logger'); +const { + EVENTS, + WEBSOCKET_MESSAGE_TYPES, + SYSTEM_VARIABLE_NAMES, + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, +} = require('../../../utils/constants'); +const { toNumber, getDeviceConfig, getFeatureBySelector } = require('./thermostat.deviceConfig'); +const { parseEnd, findMatchingPreset, getCurrentDayAndMinutes } = require('../../../utils/thermostatSchedule'); +const { + DEFAULT_PRESET_TEMPS, + FALLBACK_SETPOINT, + DEFAULT_TPI_CYCLE_TIME, + DEFAULT_TPI_PROPORTIONAL_BAND, + DEFAULT_HYSTERESIS_START, + DEFAULT_HYSTERESIS_STOP, +} = require('../../../utils/thermostatConstants'); + +const DEFAULT_TIMEZONE = 'Europe/Paris'; + +/** + * @description Resolve the setpoint feature of a thermostat device. + * Feature order is not a contract, so the feature is matched on its category + * and type rather than taken from index 0. + * @param {object} device - Thermostat device. + * @returns {object|null} The target-temperature feature, or null when absent. + * @example + * const feature = getThermostatFeature(device); + */ +function getThermostatFeature(device) { + if (!device || !Array.isArray(device.features)) { + return null; + } + return ( + device.features.find( + (feature) => + feature.category === DEVICE_FEATURE_CATEGORIES.THERMOSTAT && + feature.type === DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE, + ) || null + ); +} + +/** + * @description Get setpoint temperature for a preset from config. + * @param {string} preset - Preset name. + * @param {object} config - Thermostat config object. + * @returns {number|null} Setpoint temperature, null for the off preset. + * @example + * getSetpointForPreset('comfort', config); + */ +function getSetpointForPreset(preset, config) { + if (preset === 'off') { + return null; + } + const configValue = toNumber(config && config[`preset_${preset}`], null); + if (configValue !== null) { + return configValue; + } + return DEFAULT_PRESET_TEMPS[preset] !== undefined ? DEFAULT_PRESET_TEMPS[preset] : FALLBACK_SETPOINT; +} + +/** + * @description Derive a stable per-thermostat offset inside a TPI cycle. + * Without it every thermostat sharing a cycle time switches on at the same + * wall-clock minute, stacking the loads on the electrical installation. + * @param {string} key - Stable key, typically the thermostat feature selector. + * @param {number} cycleMinutes - TPI cycle length in minutes. + * @returns {number} Offset in minutes, within [0, cycleMinutes). + * @example + * phaseOffset('thermostat-living-room', 10); // 3 + */ +function phaseOffset(key, cycleMinutes) { + if (!key || !cycleMinutes) { + return 0; + } + let hash = 0; + for (let i = 0; i < key.length; i += 1) { + hash = (hash * 31 + key.charCodeAt(i)) % 100000; + } + return hash % cycleMinutes; +} + +/** + * @description Compute whether the switch should be active based on current temp and setpoint. + * Supports hysteresis (default) and TPI (time-proportional) control types. + * @param {number} currentTemp - Current measured temperature. + * @param {number} setpoint - Target setpoint. + * @param {string} mode - 'heating' or 'cooling'. + * @param {object} config - Thermostat config with hysteresis/TPI values. + * @param {boolean} currentSwitchOn - Whether the switch is currently ON (for neutral-zone hold). + * @param {number} [nowMs] - Current epoch in ms (for TPI cycle position, injectable in tests). + * @param {string} [phaseKey] - Stable key used to offset this thermostat inside the TPI cycle. + * @returns {boolean} True if switch should be ON. + * @example + * computeSwitchActive(18, 20, 'heating', config, false); + */ +function computeSwitchActive(currentTemp, setpoint, mode, config, currentSwitchOn, nowMs = Date.now(), phaseKey = '') { + if (currentTemp === null || currentTemp === undefined || setpoint === null || setpoint === undefined) { + return false; + } + // TPI modulates the on-time over a cycle, which suits heating only: a cooling + // compressor cannot be pulsed that way, so cooling always uses hysteresis. + if (config && config.control_type === 'tpi' && mode !== 'cooling') { + // Over each cycle, the switch is ON for a fraction of the time proportional + // to the temperature error within the proportional band. + const cycleMinutes = toNumber(config.tpi_cycle_time, DEFAULT_TPI_CYCLE_TIME); + const band = toNumber(config.tpi_proportional_band, DEFAULT_TPI_PROPORTIONAL_BAND); + const error = setpoint - currentTemp; + const onFraction = Math.min(1, Math.max(0, error / band)); + const onMinutes = onFraction * cycleMinutes; + // Regulation runs once a minute, so an on-time below one minute would ask + // for a pulse shorter than the control step: too short to start a boiler, + // and needless relay wear. Below that, stay off and let the error grow. + if (onMinutes < 1) { + return false; + } + if (onFraction >= 1) { + return true; + } + // Offset each thermostat inside the cycle, otherwise every device sharing a + // cycle time switches on at the same wall-clock minute and the loads add up. + const phase = phaseOffset(phaseKey, cycleMinutes); + const minuteInCycle = (Math.floor(nowMs / 60000) + phase) % cycleMinutes; + return minuteInCycle < onMinutes; + } + const hystStart = toNumber(config && config.hysteresis_start, DEFAULT_HYSTERESIS_START); + const hystStop = toNumber(config && config.hysteresis_stop, DEFAULT_HYSTERESIS_STOP); + if (mode === 'heating') { + if (currentTemp < setpoint - hystStart) { + return true; // too cold → ON + } + if (currentTemp > setpoint + hystStop) { + return false; // hot enough → OFF + } + return !!currentSwitchOn; // neutral zone → keep current state + } + // cooling + if (currentTemp > setpoint + hystStart) { + return true; // too hot → ON + } + if (currentTemp < setpoint - hystStop) { + return false; // cold enough → OFF + } + return !!currentSwitchOn; // neutral zone → keep current state +} + +/** + * @description Read the switch feature and actuate it if its state differs from the desired one. + * @param {object} gladys - Gladys instance. + * @param {string} switchSelector - Switch feature selector. + * @param {boolean} shouldBeActive - Desired state. + * @param {string} logContext - Context string for logs. + * @returns {Promise} + * @example + * await actuateSwitch(gladys, 'heater-switch', true, 'preset=comfort'); + */ +async function actuateSwitch(gladys, switchSelector, shouldBeActive, logContext) { + try { + const found = await getFeatureBySelector(gladys, switchSelector); + if (!found) { + logger.warn(`Thermostat schedule: switch device/feature not found for selector="${switchSelector}"`); + return; + } + const currentSwitchOn = found.feature.last_value === 1; + if (currentSwitchOn !== shouldBeActive) { + await gladys.device.setValue(found.device, found.feature, shouldBeActive ? 1 : 0); + logger.info(`Thermostat schedule: switch ${shouldBeActive ? 'ON' : 'OFF'} (${logContext})`); + } else { + logger.debug(`Thermostat schedule: switch already ${shouldBeActive ? 'ON' : 'OFF'} (${logContext})`); + } + } catch (e) { + logger.warn(`Thermostat schedule: Failed to actuate switch: ${e.message}`); + } +} + +/** + * @description Regulate a single thermostat device: window check, manual mode, + * schedule/preset resolution and switch actuation. + * @param {object} gladys - Gladys instance. + * @param {object} device - Thermostat device. + * @param {number} dayOfWeek - Current day (0=Monday … 6=Sunday). + * @param {number} currentMinutes - Current time in minutes since midnight. + * @param {string} [serviceId] - This service's id, used to scope the runtime variables. + * @returns {Promise} + * @example + * await regulateDevice(gladys, device, 0, 480, serviceId); + */ +async function regulateDevice(gladys, device, dayOfWeek, currentMinutes, serviceId = null) { + const thermostatFeature = getThermostatFeature(device); + if (!thermostatFeature) { + logger.debug('Thermostat schedule: device has no target-temperature feature, skipping'); + return; + } + const { selector } = thermostatFeature; + const featureKey = selector.toUpperCase().replace(/-/g, '_'); + const presetVarKey = `THERMOSTAT_${featureKey}_PRESET`; + const manualVarKey = `THERMOSTAT_${featureKey}_MANUAL_MODE`; + + const config = getDeviceConfig(device); + if (!config) { + logger.warn(`Thermostat schedule: no config found for ${selector}`); + return; + } + // getDeviceConfig always fills this in from THERMOSTAT_MODE or the shared default. + const { default_mode: mode } = config; + + // Window open check: if a window sensor is configured and open, cut the switch and stop here. + if (config.window_feature) { + try { + const win = await getFeatureBySelector(gladys, config.window_feature); + if (win && win.feature.last_value === 0) { + logger.info(`Thermostat schedule: window open for ${selector}, switch OFF`); + if (config.switch_feature) { + await actuateSwitch(gladys, config.switch_feature, false, `window open, ${selector}`); + } + return; + } + } catch (e) { + logger.warn(`Thermostat schedule: Failed to read window sensor: ${e.message}`); + } + } + + const [currentPreset, manualVal] = await Promise.all([ + gladys.variable.getValue(presetVarKey, serviceId).catch(() => null), + gladys.variable.getValue(manualVarKey, serviceId).catch(() => null), + ]); + + // Manual mode: regulate on the manual setpoint until the timer expires. + let manualJustExpired = false; + if (manualVal === 'true') { + const manualUntilKey = `THERMOSTAT_${featureKey}_MANUAL_UNTIL`; + const manualUntilVal = await gladys.variable.getValue(manualUntilKey, serviceId).catch(() => null); + const manualUntil = manualUntilVal ? parseInt(manualUntilVal, 10) : null; + if (manualUntil && Date.now() > manualUntil) { + logger.info(`Thermostat schedule: manual timer expired for ${selector}, reverting to schedule`); + await gladys.variable.setValue(manualVarKey, 'false', serviceId); + await gladys.variable.setValue(manualUntilKey, '', serviceId); + gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.THERMOSTAT.MANUAL_MODE_UPDATED, + payload: { key: manualVarKey, value: 'false' }, + }); + manualJustExpired = true; + // Fall through — the schedule/preset is applied below + } else { + const manualSetpointRaw = await gladys.variable + .getValue(`THERMOSTAT_${featureKey}_MANUAL_SETPOINT`, serviceId) + .catch(() => null); + let manualSetpoint = null; + if (manualSetpointRaw) { + try { + const parsed = JSON.parse(manualSetpointRaw); + manualSetpoint = toNumber(parsed && parsed.setpoint, null); + } catch (e) { + /* ignore */ + } + } + if (manualSetpoint !== null && config.switch_feature && config.temperature_feature) { + const tmp = await getFeatureBySelector(gladys, config.temperature_feature); + const sw = await getFeatureBySelector(gladys, config.switch_feature); + if (tmp && sw && tmp.feature.last_value !== null) { + const shouldBeActive = computeSwitchActive( + tmp.feature.last_value, + manualSetpoint, + mode, + config, + sw.feature.last_value === 1, + Date.now(), + selector, + ); + await actuateSwitch( + gladys, + config.switch_feature, + shouldBeActive, + `manual, setpoint=${manualSetpoint}, temp=${tmp.feature.last_value}, ${selector}`, + ); + } + } + return; + } + } + + // Resolve the target preset: schedule slot first, then the current preset variable. + // A thermostat without schedule (or between slots) keeps being regulated on its preset. + // The active schedule is device-owned: dashboards only choose which thermostat to + // display, so a private dashboard can never drive the regulation of the whole house. + const scheduleSelector = config.active_schedule || null; + + let targetPreset = null; + if (scheduleSelector) { + const schedule = await db.ThermostatSchedule.findOne({ + where: { selector: scheduleSelector }, + include: [{ model: db.ThermostatScheduleSlot, as: 'slots' }], + }); + if (schedule) { + const slotsForToday = schedule.slots.filter((s) => s.day_of_week === dayOfWeek); + const yesterdayOfWeek = (dayOfWeek + 6) % 7; + const slotsForYesterday = schedule.slots.filter((s) => s.day_of_week === yesterdayOfWeek); + targetPreset = findMatchingPreset(slotsForToday, slotsForYesterday, currentMinutes); + } + } + if (!targetPreset) { + targetPreset = currentPreset || null; + } + if (!targetPreset) { + logger.debug(`Thermostat schedule: no preset resolved for ${selector}, nothing to regulate`); + return; + } + + // Enforce the target setpoint on the thermostat feature, only when it changed. + const newSetpoint = getSetpointForPreset(targetPreset, config); + if (newSetpoint !== null && thermostatFeature.last_value !== newSetpoint) { + try { + await gladys.device.saveState(thermostatFeature, newSetpoint); + } catch (e) { + logger.warn(`Thermostat schedule: Failed to update setpoint: ${e.message}`); + } + } + + // Persist + notify the preset when it changed, and also when leaving manual + // mode: dashboards then display the manual preset, so they need the schedule + // preset pushed back even though the stored value never moved. + if (currentPreset !== targetPreset || manualJustExpired) { + await gladys.variable.setValue(presetVarKey, targetPreset, serviceId); + logger.info(`Thermostat schedule: preset "${targetPreset}" applied to ${selector}`); + gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.THERMOSTAT.PRESET_UPDATED, + payload: { key: presetVarKey, value: targetPreset }, + }); + } + + if (!config.switch_feature) { + logger.debug(`Thermostat schedule: no switch_feature configured for ${selector}, cannot actuate switch`); + return; + } + + if (targetPreset === 'off') { + await actuateSwitch(gladys, config.switch_feature, false, `preset=off, ${selector}`); + return; + } + + if (!config.temperature_feature) { + logger.warn(`Thermostat schedule: no temperature_feature configured for ${selector}, cannot compute switch state`); + return; + } + + let currentTemp = null; + try { + const tmp = await getFeatureBySelector(gladys, config.temperature_feature); + currentTemp = tmp ? tmp.feature.last_value : null; + } catch (e) { + logger.warn(`Thermostat schedule: Failed to read temperature: ${e.message}`); + return; + } + if (currentTemp === null || currentTemp === undefined) { + logger.warn( + `Thermostat schedule: no temperature reading for ${config.temperature_feature}, cannot compute switch state`, + ); + return; + } + + const sw = await getFeatureBySelector(gladys, config.switch_feature); + if (!sw) { + logger.warn(`Thermostat schedule: switch device/feature not found for selector="${config.switch_feature}"`); + return; + } + const shouldBeActive = computeSwitchActive( + currentTemp, + newSetpoint, + mode, + config, + sw.feature.last_value === 1, + Date.now(), + selector, + ); + await actuateSwitch( + gladys, + config.switch_feature, + shouldBeActive, + `preset="${targetPreset}", temp=${currentTemp}, setpoint=${newSetpoint}, ${selector}`, + ); +} + +/** + * @description Regulate all thermostats. Called every minute by the service interval. + * Resolves the target preset (schedule slot, or current preset when no schedule), + * updates the setpoint/preset when they changed, and actuates the switch + * (hysteresis or TPI). The server is the single control authority. + * @returns {Promise} + * @example + * await thermostatHandler.applySchedules(); + */ +async function applySchedules() { + try { + const thermostatDevices = await this.gladys.device.get({ service: 'thermostat' }); + if (!thermostatDevices || thermostatDevices.length === 0) { + logger.debug('Thermostat schedule: no thermostat devices found'); + return; + } + logger.debug(`Thermostat schedule: found ${thermostatDevices.length} thermostat device(s)`); + + // Schedules are wall-clock times in the house, and official Gladys images run + // in UTC: read the day and time in the configured timezone, like scenes do. + const timezone = + (await this.gladys.variable.getValue(SYSTEM_VARIABLE_NAMES.TIMEZONE).catch(() => null)) || DEFAULT_TIMEZONE; + const { dayOfWeek, currentMinutes } = getCurrentDayAndMinutes(new Date(), timezone); + + await Promise.all( + thermostatDevices.map(async (device) => { + try { + await regulateDevice(this.gladys, device, dayOfWeek, currentMinutes, this.serviceId); + } catch (e) { + logger.warn(`Thermostat schedule: Failed to regulate device: ${e.message}`); + } + }), + ); + } catch (e) { + logger.warn(`Thermostat applySchedules error: ${e.message}`); + } +} + +module.exports = { + applySchedules, + getThermostatFeature, + phaseOffset, + regulateDevice, + parseEnd, + findMatchingPreset, + getSetpointForPreset, + computeSwitchActive, +}; diff --git a/server/services/thermostat/lib/thermostat.createDevice.js b/server/services/thermostat/lib/thermostat.createDevice.js new file mode 100644 index 0000000000..c0fa57465b --- /dev/null +++ b/server/services/thermostat/lib/thermostat.createDevice.js @@ -0,0 +1,72 @@ +const logger = require('../../../utils/logger'); +const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../utils/constants'); + +// Params the integration owns. Anything else sent by a client is dropped rather +// than persisted, so the device never carries unknown regulation settings. +const ALLOWED_PARAMS = [ + 'THERMOSTAT_TEMPERATURE_FEATURE', + 'THERMOSTAT_HUMIDITY_FEATURE', + 'THERMOSTAT_SWITCH_FEATURE', + 'THERMOSTAT_WINDOW_FEATURE', + 'THERMOSTAT_ACTIVE_SCHEDULE', + 'THERMOSTAT_MODE', + 'THERMOSTAT_CONTROL_TYPE', + 'THERMOSTAT_MIN_TEMP', + 'THERMOSTAT_MAX_TEMP', + 'THERMOSTAT_TEMP_UNIT', + 'THERMOSTAT_MANUAL_DURATION', + 'THERMOSTAT_PRESET_FROST', + 'THERMOSTAT_PRESET_AWAY', + 'THERMOSTAT_PRESET_ECO', + 'THERMOSTAT_PRESET_NIGHT', + 'THERMOSTAT_PRESET_COMFORT', + 'THERMOSTAT_HYSTERESIS_START', + 'THERMOSTAT_HYSTERESIS_STOP', + 'THERMOSTAT_TPI_CYCLE_TIME', + 'THERMOSTAT_TPI_PROPORTIONAL_BAND', +]; + +/** + * @description Create a thermostat device linked to this service. + * The payload is narrowed to what this integration owns: a single + * thermostat/target-temperature feature and the known THERMOSTAT_* params. + * Forwarding the request body as-is would let a client persist arbitrary + * features and params on the device. + * @param {object} device - Device to create. + * @returns {Promise} Created device. + * @example + * await gladys.services.thermostat.device.createDevice({ name: 'Thermostat Salon', ... }); + */ +async function createDevice(device) { + logger.info(`Thermostat: Creating device "${device.name}"`); + + const features = (device.features || []).filter( + (feature) => + feature.category === DEVICE_FEATURE_CATEGORIES.THERMOSTAT && + feature.type === DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE, + ); + if (features.length === 0) { + throw new Error('Thermostat: a thermostat device needs a thermostat/target-temperature feature'); + } + + const params = (device.params || []).filter((param) => ALLOWED_PARAMS.includes(param.name)); + + const createdDevice = await this.gladys.device.create({ + id: device.id, + name: device.name, + selector: device.selector, + external_id: device.external_id, + room_id: device.room_id, + model: device.model, + should_poll: false, + features: features.slice(0, 1), + params, + service_id: this.serviceId, + }); + // The window sensor may have changed: drop the cached selectors so the next + // NEW_STATE event rebuilds them. + this.invalidateWindowCache(); + return createdDevice; +} + +module.exports = { createDevice, ALLOWED_PARAMS }; diff --git a/server/services/thermostat/lib/thermostat.createSchedule.js b/server/services/thermostat/lib/thermostat.createSchedule.js new file mode 100644 index 0000000000..290d84f713 --- /dev/null +++ b/server/services/thermostat/lib/thermostat.createSchedule.js @@ -0,0 +1,48 @@ +const db = require('../../../models'); +const { slugify } = require('../../../utils/slugify'); +const logger = require('../../../utils/logger'); +const { validateSchedule } = require('../../../utils/thermostatValidateSchedule'); + +/** + * @description Create a thermostat schedule with its slots. + * @param {object} scheduleData - Schedule data: { name, slots }. + * @returns {Promise} Created schedule. + * @example + * await thermostatHandler.createSchedule({ name: 'Vacances', slots: [] }); + */ +async function createSchedule(scheduleData) { + logger.info(`Thermostat: Creating schedule "${scheduleData.name}"`); + + // Use the validated payload, not the raw one: Joi coerces day_of_week to a + // number and defaults slots to [], and persisting the raw values would store + // a string day that then matches no regulation tick. + const validated = validateSchedule(scheduleData); + + const existing = await db.ThermostatSchedule.findOne({ where: { name: validated.name } }); + if (existing) { + throw new Error(`A schedule with the name "${validated.name}" already exists`); + } + + const selector = slugify(`${validated.name}-${Date.now()}`, true); + + const created = await db.ThermostatSchedule.create( + { + name: validated.name, + selector, + slots: validated.slots.map((slot) => ({ + day_of_week: slot.day_of_week, + start_time: slot.start_time, + end_time: slot.end_time, + preset: slot.preset, + })), + }, + { include: [{ model: db.ThermostatScheduleSlot, as: 'slots' }] }, + ); + + const result = await db.ThermostatSchedule.findByPk(created.id, { + include: [{ model: db.ThermostatScheduleSlot, as: 'slots' }], + }); + return result.get({ plain: true }); +} + +module.exports = { createSchedule }; diff --git a/server/services/thermostat/lib/thermostat.deleteSchedule.js b/server/services/thermostat/lib/thermostat.deleteSchedule.js new file mode 100644 index 0000000000..e5dcc77cee --- /dev/null +++ b/server/services/thermostat/lib/thermostat.deleteSchedule.js @@ -0,0 +1,21 @@ +const db = require('../../../models'); +const logger = require('../../../utils/logger'); + +/** + * @description Delete a thermostat schedule and all its slots. + * @param {string} selector - Schedule selector. + * @returns {Promise} + * @example + * await thermostatHandler.deleteSchedule('my-schedule'); + */ +async function deleteSchedule(selector) { + logger.info(`Thermostat: Deleting schedule "${selector}"`); + const schedule = await db.ThermostatSchedule.findOne({ where: { selector } }); + if (!schedule) { + throw new Error(`Schedule not found: ${selector}`); + } + await db.ThermostatScheduleSlot.destroy({ where: { schedule_id: schedule.id } }); + await schedule.destroy(); +} + +module.exports = { deleteSchedule }; diff --git a/server/services/thermostat/lib/thermostat.deviceConfig.js b/server/services/thermostat/lib/thermostat.deviceConfig.js new file mode 100644 index 0000000000..671f00f6a2 --- /dev/null +++ b/server/services/thermostat/lib/thermostat.deviceConfig.js @@ -0,0 +1,103 @@ +const { + DEFAULT_PRESET_TEMPS, + DEFAULT_HYSTERESIS_START, + DEFAULT_HYSTERESIS_STOP, + DEFAULT_TPI_CYCLE_TIME, + DEFAULT_TPI_PROPORTIONAL_BAND, + DEFAULT_MODE, + DEFAULT_CONTROL_TYPE, + DEFAULT_MIN_TEMP, + DEFAULT_MAX_TEMP, + DEFAULT_TEMP_UNIT, + DEFAULT_MANUAL_DURATION_MINUTES, +} = require('../../../utils/thermostatConstants'); + +/** + * @description Parse a value as a finite number, falling back to a default. + * Unlike `parseFloat(x) || d`, a legitimate 0 is preserved. + * @param {*} value - Raw value (string, number, null...). + * @param {number|null} defaultValue - Fallback when the value is not a finite number. + * @returns {number|null} Parsed number or the default. + * @example + * toNumber('0', 7); // 0 + */ +function toNumber(value, defaultValue) { + const n = parseFloat(value); + return Number.isFinite(n) ? n : defaultValue; +} + +/** + * @description Build a thermostat config object from device params. + * @param {object} device - Thermostat device with params. + * @returns {object|null} Config object, or null when the device has no params. + * @example + * const config = buildParamsConfig(device); + */ +function buildParamsConfig(device) { + if (!device.params || device.params.length === 0) { + return null; + } + const getParam = (name) => { + const p = device.params.find((x) => x.name === name); + return p ? p.value : null; + }; + return { + temperature_feature: getParam('THERMOSTAT_TEMPERATURE_FEATURE') || null, + humidity_feature: getParam('THERMOSTAT_HUMIDITY_FEATURE') || null, + switch_feature: getParam('THERMOSTAT_SWITCH_FEATURE') || null, + window_feature: getParam('THERMOSTAT_WINDOW_FEATURE') || null, + // Device-owned: the widget only chooses which thermostat to display. + active_schedule: getParam('THERMOSTAT_ACTIVE_SCHEDULE') || null, + default_mode: getParam('THERMOSTAT_MODE') || DEFAULT_MODE, + control_type: getParam('THERMOSTAT_CONTROL_TYPE') || DEFAULT_CONTROL_TYPE, + temp_min: toNumber(getParam('THERMOSTAT_MIN_TEMP'), DEFAULT_MIN_TEMP), + temp_max: toNumber(getParam('THERMOSTAT_MAX_TEMP'), DEFAULT_MAX_TEMP), + temp_unit: getParam('THERMOSTAT_TEMP_UNIT') || DEFAULT_TEMP_UNIT, + manual_duration: toNumber(getParam('THERMOSTAT_MANUAL_DURATION'), DEFAULT_MANUAL_DURATION_MINUTES), + preset_frost: toNumber(getParam('THERMOSTAT_PRESET_FROST'), DEFAULT_PRESET_TEMPS.frost), + preset_away: toNumber(getParam('THERMOSTAT_PRESET_AWAY'), DEFAULT_PRESET_TEMPS.away), + preset_eco: toNumber(getParam('THERMOSTAT_PRESET_ECO'), DEFAULT_PRESET_TEMPS.eco), + preset_night: toNumber(getParam('THERMOSTAT_PRESET_NIGHT'), DEFAULT_PRESET_TEMPS.night), + preset_comfort: toNumber(getParam('THERMOSTAT_PRESET_COMFORT'), DEFAULT_PRESET_TEMPS.comfort), + hysteresis_start: toNumber(getParam('THERMOSTAT_HYSTERESIS_START'), DEFAULT_HYSTERESIS_START), + hysteresis_stop: toNumber(getParam('THERMOSTAT_HYSTERESIS_STOP'), DEFAULT_HYSTERESIS_STOP), + tpi_cycle_time: toNumber(getParam('THERMOSTAT_TPI_CYCLE_TIME'), DEFAULT_TPI_CYCLE_TIME), + tpi_proportional_band: toNumber(getParam('THERMOSTAT_TPI_PROPORTIONAL_BAND'), DEFAULT_TPI_PROPORTIONAL_BAND), + }; +} + +/** + * @description Load the full config of a thermostat device. + * Device params are the single source of truth: a control loop that actuates + * real heaters must not depend on a JSON blob in the variable table that only + * the integration page ever wrote. + * @param {object} device - Thermostat device. + * @returns {object|null} Config built from the device params, or null. + * @example + * const config = getDeviceConfig(device); + */ +function getDeviceConfig(device) { + return buildParamsConfig(device); +} + +/** + * @description Fetch a device feature (and its device) by feature selector. + * @param {object} gladys - Gladys instance. + * @param {string} selector - Device feature selector. + * @returns {Promise<{device: object, feature: object}|null>} Device + feature, or null. + * @example + * const found = await getFeatureBySelector(gladys, 'heater-switch'); + */ +async function getFeatureBySelector(gladys, selector) { + const devices = await gladys.device.get({ device_feature_selectors: selector }); + const device = devices && devices[0]; + const feature = device && device.features.find((f) => f.selector === selector); + return device && feature ? { device, feature } : null; +} + +module.exports = { + toNumber, + buildParamsConfig, + getDeviceConfig, + getFeatureBySelector, +}; diff --git a/server/services/thermostat/lib/thermostat.getDevices.js b/server/services/thermostat/lib/thermostat.getDevices.js new file mode 100644 index 0000000000..9d524156f6 --- /dev/null +++ b/server/services/thermostat/lib/thermostat.getDevices.js @@ -0,0 +1,23 @@ +const logger = require('../../../utils/logger'); + +/** + * @description Get all thermostat devices linked to this service. + * @param {object} options - Optional filters: search, order_dir. + * @returns {Promise} List of thermostat devices. + * @example + * await gladys.services.thermostat.device.getDevices({ search: 'salon', order_dir: 'desc' }); + */ +async function getDevices(options = {}) { + logger.info('Thermostat: Getting devices'); + const query = { service: 'thermostat' }; + if (options.search) { + query.search = options.search; + } + if (options.order_dir) { + query.order_dir = options.order_dir; + } + const devices = await this.gladys.device.get(query); + return devices; +} + +module.exports = { getDevices }; diff --git a/server/services/thermostat/lib/thermostat.getSchedules.js b/server/services/thermostat/lib/thermostat.getSchedules.js new file mode 100644 index 0000000000..9c6e54b83c --- /dev/null +++ b/server/services/thermostat/lib/thermostat.getSchedules.js @@ -0,0 +1,21 @@ +const db = require('../../../models'); + +/** + * @description Get all thermostat schedules. + * @returns {Promise} List of schedules with their slots. + * @example + * await gladys.services.thermostat.device.getSchedules(); + */ +async function getSchedules() { + const schedules = await db.ThermostatSchedule.findAll({ + include: [{ model: db.ThermostatScheduleSlot, as: 'slots' }], + order: [ + ['name', 'ASC'], + [{ model: db.ThermostatScheduleSlot, as: 'slots' }, 'day_of_week', 'ASC'], + [{ model: db.ThermostatScheduleSlot, as: 'slots' }, 'start_time', 'ASC'], + ], + }); + return schedules.map((s) => s.get({ plain: true })); +} + +module.exports = { getSchedules }; diff --git a/server/services/thermostat/lib/thermostat.onWindowOpen.js b/server/services/thermostat/lib/thermostat.onWindowOpen.js new file mode 100644 index 0000000000..c677e36166 --- /dev/null +++ b/server/services/thermostat/lib/thermostat.onWindowOpen.js @@ -0,0 +1,111 @@ +const logger = require('../../../utils/logger'); +const { getThermostatFeature } = require('./thermostat.applySchedules'); +const { buildParamsConfig, getFeatureBySelector } = require('./thermostat.deviceConfig'); + +/** + * @description Invalidate the cached window-sensor selectors. Called whenever a + * thermostat device is created or deleted, so the next event rebuilds the map. + * @returns {undefined} + * @example + * thermostatHandler.invalidateWindowCache(); + */ +function invalidateWindowCache() { + this.windowSelectorsCache = null; +} + +/** + * @description The set of window-sensor selectors configured on the thermostats. + * EVENTS.DEVICE.NEW_STATE fires for every feature in the house, so without this + * cache every binary sensor reaching 0 would trigger a device query. + * @returns {Promise>} Configured window selectors. + * @example + * const selectors = await thermostatHandler.getWindowSelectors(); + */ +async function getWindowSelectors() { + if (this.windowSelectorsCache) { + return this.windowSelectorsCache; + } + const devices = await this.gladys.device.get({ service: 'thermostat' }); + const selectors = new Set(); + (devices || []).forEach((device) => { + const config = buildParamsConfig(device); + if (config && config.window_feature) { + selectors.add(config.window_feature); + } + }); + this.windowSelectorsCache = selectors; + return selectors; +} + +/** + * @description Called when a device feature state changes. + * If the feature is a configured window sensor and the window is now open, + * immediately turn off the associated heating switch. + * Services emit EVENTS.DEVICE.NEW_STATE with { device_feature_external_id, state }; + * the legacy { device_feature, last_value } shape is also accepted. + * @param {object} event - The device new-state event payload. + * @returns {Promise} + * @example + * await thermostatHandler.onDeviceNewState({ device_feature_external_id: 'zigbee2mqtt:xx', state: 0 }); + */ +async function onDeviceNewState(event) { + if (!event) { + return; + } + const newValue = event.state !== undefined ? event.state : event.last_value; + if (newValue !== 0) { + return; + } + let changedSelector = event.device_feature || event.device_feature_selector || null; + if (!changedSelector && event.device_feature_external_id) { + const feature = this.gladys.stateManager.get('deviceFeatureByExternalId', event.device_feature_external_id); + changedSelector = feature ? feature.selector : null; + } + if (!changedSelector) { + return; + } + try { + // Cheap rejection first: most events in a house are not a configured window. + const windowSelectors = await getWindowSelectors.call(this); + if (!windowSelectors.has(changedSelector)) { + return; + } + + const thermostatDevices = await this.gladys.device.get({ service: 'thermostat' }); + if (!thermostatDevices || thermostatDevices.length === 0) { + return; + } + await Promise.all( + thermostatDevices.map(async (device) => { + const thermostatFeature = getThermostatFeature(device); + if (!thermostatFeature) { + return; + } + // Window and switch are device-owned params: no dashboard read here. + // buildParamsConfig already returns null fields for the params it misses, + // so the checks below cover both an unconfigured and an absent config. + const paramsConfig = buildParamsConfig(device) || {}; + const { window_feature: windowFeature, switch_feature: switchFeature } = paramsConfig; + if (windowFeature !== changedSelector || !switchFeature) { + return; + } + logger.info( + `Thermostat: window opened (${changedSelector})` + + ` for ${thermostatFeature.selector}, turning switch OFF immediately`, + ); + try { + const sw = await getFeatureBySelector(this.gladys, switchFeature); + if (sw && sw.feature.last_value !== 0) { + await this.gladys.device.setValue(sw.device, sw.feature, 0); + } + } catch (e) { + logger.warn(`Thermostat: Failed to turn off switch on window open: ${e.message}`); + } + }), + ); + } catch (e) { + logger.warn(`Thermostat onDeviceNewState error: ${e.message}`); + } +} + +module.exports = { onDeviceNewState, getWindowSelectors, invalidateWindowCache }; diff --git a/server/services/thermostat/lib/thermostat.postDelete.js b/server/services/thermostat/lib/thermostat.postDelete.js new file mode 100644 index 0000000000..2dee9f77bc --- /dev/null +++ b/server/services/thermostat/lib/thermostat.postDelete.js @@ -0,0 +1,38 @@ +const logger = require('../../../utils/logger'); +const { RUNTIME_SUFFIXES } = require('./thermostat.setVariable'); + +// Runtime state kept per thermostat feature, outside the device row. Shared with +// the write path so a suffix added there is cleaned up here too. +const VARIABLE_SUFFIXES = RUNTIME_SUFFIXES; + +/** + * @description Called after a thermostat device is deleted: drop the runtime + * variables attached to its features. Without this they linger in the variable + * table forever and a device recreated with the same selector would inherit a + * stale preset or a manual override. + * @param {object} device - The deleted device. + * @returns {Promise} + * @example + * await thermostatHandler.postDelete(device); + */ +async function postDelete(device) { + this.invalidateWindowCache(); + const features = (device && device.features) || []; + await Promise.all( + features.map(async (feature) => { + const featureKey = feature.selector.toUpperCase().replace(/-/g, '_'); + const keys = VARIABLE_SUFFIXES.map((suffix) => `THERMOSTAT_${featureKey}_${suffix}`); + await Promise.all( + keys.map(async (key) => { + try { + await this.gladys.variable.destroy(key, this.serviceId); + } catch (e) { + logger.debug(`Thermostat: could not remove variable ${key}: ${e.message}`); + } + }), + ); + }), + ); +} + +module.exports = { postDelete, VARIABLE_SUFFIXES }; diff --git a/server/services/thermostat/lib/thermostat.setValue.js b/server/services/thermostat/lib/thermostat.setValue.js new file mode 100644 index 0000000000..dd99da7a67 --- /dev/null +++ b/server/services/thermostat/lib/thermostat.setValue.js @@ -0,0 +1,46 @@ +const logger = require('../../../utils/logger'); +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../utils/constants'); +const { DEFAULT_MANUAL_DURATION_MINUTES } = require('../../../utils/thermostatConstants'); +const { buildParamsConfig, toNumber } = require('./thermostat.deviceConfig'); + +/** + * @description Set a thermostat device feature value (for example the setpoint). + * This is the path taken by scenes (`device.set-value`) and by the generic device + * API. Persisting the value alone would not survive: the next regulation pass + * re-applies the scheduled preset and overwrites it within a minute. So an + * external write is treated as a manual override, exactly like turning the dial + * on the widget — the setpoint holds for the device's configured manual + * duration (THERMOSTAT_MANUAL_DURATION, in minutes), then the schedule takes + * over again. + * @param {object} device - The device object. + * @param {object} deviceFeature - The device feature to update. + * @param {number} value - The new value. + * @returns {Promise} + * @example + * await service.device.setValue(device, deviceFeature, 21.5); + */ +async function setValue(device, deviceFeature, value) { + await this.gladys.device.saveState(deviceFeature, value); + + const featureKey = deviceFeature.selector.toUpperCase().replace(/-/g, '_'); + const manualVarKey = `THERMOSTAT_${featureKey}_MANUAL_MODE`; + const manualUntilKey = `THERMOSTAT_${featureKey}_MANUAL_UNTIL`; + const manualSetpointKey = `THERMOSTAT_${featureKey}_MANUAL_SETPOINT`; + const config = buildParamsConfig(device) || {}; + const durationMinutes = toNumber(config.manual_duration, DEFAULT_MANUAL_DURATION_MINUTES); + const manualUntil = Date.now() + durationMinutes * 60 * 1000; + + await this.gladys.variable.setValue(manualSetpointKey, JSON.stringify({ setpoint: value }), this.serviceId); + await this.gladys.variable.setValue(manualUntilKey, String(manualUntil), this.serviceId); + await this.gladys.variable.setValue(manualVarKey, 'true', this.serviceId); + + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.THERMOSTAT.MANUAL_MODE_UPDATED, + payload: { key: manualVarKey, value: 'true' }, + }); + + logger.info(`Thermostat: external setValue on ${deviceFeature.selector} held as manual setpoint ${value}`); + this.triggerApplySchedules(); +} + +module.exports = { setValue }; diff --git a/server/services/thermostat/lib/thermostat.setVariable.js b/server/services/thermostat/lib/thermostat.setVariable.js new file mode 100644 index 0000000000..85022600e0 --- /dev/null +++ b/server/services/thermostat/lib/thermostat.setVariable.js @@ -0,0 +1,121 @@ +const logger = require('../../../utils/logger'); +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../utils/constants'); + +const APPLY_DEBOUNCE_MS = 2000; + +// Per-thermostat runtime state, keyed THERMOSTAT__. The +// configuration is not in this list on purpose: it lives on the device, and +// letting a client write it back here would recreate a second source of truth. +const RUNTIME_SUFFIXES = ['PRESET', 'PRESET_FALLBACK', 'MANUAL_MODE', 'MANUAL_UNTIL', 'MANUAL_SETPOINT']; + +/** + * @description Whether a variable key is a thermostat runtime key this service owns. + * @param {string} variableKey - Variable key to check. + * @returns {boolean} True when the key is a known runtime key. + * @example + * isRuntimeVariableKey('THERMOSTAT_LIVING_ROOM_PRESET'); // true + */ +function isRuntimeVariableKey(variableKey) { + if (!variableKey || !variableKey.startsWith('THERMOSTAT_')) { + return false; + } + return RUNTIME_SUFFIXES.some((suffix) => variableKey.endsWith(`_${suffix}`)); +} + +/** + * @description Set a thermostat runtime variable, broadcast the matching websocket + * message so every open dashboard refreshes, and schedule a debounced regulation pass. + * Only the runtime keys are accepted: the configuration lives on the device. + * @param {string} variableKey - Variable key, THERMOSTAT__. + * @param {string} value - Variable value. + * @returns {Promise} The saved variable. + * @example + * await thermostatHandler.setVariable('THERMOSTAT_MY_DEVICE_PRESET', 'comfort'); + */ +async function setVariable(variableKey, value) { + if (!isRuntimeVariableKey(variableKey)) { + throw new Error(`Invalid thermostat variable key: ${variableKey}`); + } + // Scoped to this service: unscoped rows sit in the global variable table and + // postDelete would have to guess their names to clean them up. + const variable = await this.gladys.variable.setValue(variableKey, value, this.serviceId); + + let messageType = null; + if (variableKey.endsWith('_PRESET')) { + messageType = WEBSOCKET_MESSAGE_TYPES.THERMOSTAT.PRESET_UPDATED; + } else if (variableKey.endsWith('_MANUAL_MODE')) { + messageType = WEBSOCKET_MESSAGE_TYPES.THERMOSTAT.MANUAL_MODE_UPDATED; + } + if (messageType) { + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: messageType, + payload: { key: variableKey, value }, + }); + } + + this.triggerApplySchedules(); + return variable; +} + +/** + * @description Tell every open dashboard that a thermostat's configuration + * changed, so the widgets reload it from the device. The payload carries no + * configuration: the device is the single store, and sending a copy here would + * be a second one that could disagree with it. + * @returns {undefined} + * @example + * thermostatHandler.broadcastConfigUpdated(); + */ +function broadcastConfigUpdated() { + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.THERMOSTAT.CONFIG_UPDATED, + payload: {}, + }); +} + +/** + * @description Read a thermostat runtime variable in this service's scope. + * @param {string} variableKey - Variable key. + * @returns {Promise} The stored value, or null. + * @example + * await thermostatHandler.getVariable('THERMOSTAT_LIVING_ROOM_PRESET'); + */ +async function getVariable(variableKey) { + if (!isRuntimeVariableKey(variableKey)) { + return null; + } + return this.gladys.variable.getValue(variableKey, this.serviceId); +} + +/** + * @description Schedule a debounced applySchedules run, so a burst of variable + * writes (preset + manual mode + manual setpoint) triggers a single regulation + * pass a couple of seconds later instead of waiting for the next minute tick. + * @returns {undefined} + * @example + * thermostatHandler.triggerApplySchedules(); + */ +function triggerApplySchedules() { + const handler = this; + if (handler.applyTimer) { + clearTimeout(handler.applyTimer); + } + handler.applyTimer = setTimeout(async () => { + handler.applyTimer = null; + try { + // @ts-ignore — handler is the ThermostatHandler instance, applySchedules is on its prototype + await handler.applySchedules(); + } catch (e) { + logger.warn(`Thermostat: debounced applySchedules failed: ${e.message}`); + } + }, APPLY_DEBOUNCE_MS); +} + +module.exports = { + setVariable, + getVariable, + broadcastConfigUpdated, + triggerApplySchedules, + isRuntimeVariableKey, + RUNTIME_SUFFIXES, +}; diff --git a/server/services/thermostat/lib/thermostat.updateSchedule.js b/server/services/thermostat/lib/thermostat.updateSchedule.js new file mode 100644 index 0000000000..ac4cf9f8ed --- /dev/null +++ b/server/services/thermostat/lib/thermostat.updateSchedule.js @@ -0,0 +1,61 @@ +const db = require('../../../models'); +const logger = require('../../../utils/logger'); +const { validateSchedule } = require('../../../utils/thermostatValidateSchedule'); + +/** + * @description Update a thermostat schedule (name + full replace of slots). + * @param {string} selector - Schedule selector. + * @param {object} scheduleData - Updated data: { name, slots }. + * @returns {Promise} Updated schedule with slots. + * @example + * await thermostatHandler.updateSchedule('my-schedule', { name: 'New name', slots: [] }); + */ +async function updateSchedule(selector, scheduleData) { + logger.info(`Thermostat: Updating schedule "${selector}"`); + + // Use the validated payload: Joi coerces day_of_week and defaults slots to []. + const validated = validateSchedule(scheduleData); + + const schedule = await db.ThermostatSchedule.findOne({ where: { selector } }); + if (!schedule) { + throw new Error(`Schedule not found: ${selector}`); + } + + const duplicate = await db.ThermostatSchedule.findOne({ + where: { name: validated.name }, + }); + if (duplicate && duplicate.id !== schedule.id) { + throw new Error(`A schedule with the name "${validated.name}" already exists`); + } + + // Replace name + slots atomically: a failure mid-way must not lose the existing slots + await db.sequelize.transaction(async (transaction) => { + await schedule.update({ name: validated.name }, { transaction }); + + await db.ThermostatScheduleSlot.destroy({ where: { schedule_id: schedule.id }, transaction }); + + if (validated.slots.length > 0) { + await db.ThermostatScheduleSlot.bulkCreate( + validated.slots.map((slot) => ({ + schedule_id: schedule.id, + day_of_week: slot.day_of_week, + start_time: slot.start_time, + end_time: slot.end_time, + preset: slot.preset, + })), + { transaction }, + ); + } + }); + + const result = await db.ThermostatSchedule.findByPk(schedule.id, { + include: [{ model: db.ThermostatScheduleSlot, as: 'slots' }], + order: [ + [{ model: db.ThermostatScheduleSlot, as: 'slots' }, 'day_of_week', 'ASC'], + [{ model: db.ThermostatScheduleSlot, as: 'slots' }, 'start_time', 'ASC'], + ], + }); + return result.get({ plain: true }); +} + +module.exports = { updateSchedule }; diff --git a/server/services/thermostat/package.json b/server/services/thermostat/package.json new file mode 100644 index 0000000000..3cf8f59206 --- /dev/null +++ b/server/services/thermostat/package.json @@ -0,0 +1,16 @@ +{ + "name": "gladys-thermostat", + "version": "1.0.0", + "main": "index.js", + "os": [ + "darwin", + "linux", + "win32" + ], + "cpu": [ + "x64", + "arm", + "arm64" + ], + "dependencies": {} +} diff --git a/server/test/services/thermostat/api/thermostat.controller.test.js b/server/test/services/thermostat/api/thermostat.controller.test.js new file mode 100644 index 0000000000..1093484dab --- /dev/null +++ b/server/test/services/thermostat/api/thermostat.controller.test.js @@ -0,0 +1,350 @@ +const { expect } = require('chai'); +const sinon = require('sinon').createSandbox(); + +const { fake, assert } = sinon; + +const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../../utils/constants'); +const ThermostatController = require('../../../../services/thermostat/api/thermostat.controller'); + +const setpointFeature = { + selector: 'thermostat-living-room', + category: DEVICE_FEATURE_CATEGORIES.THERMOSTAT, + type: DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE, +}; +const thermostatDevice = { selector: 'my-thermostat', features: [setpointFeature] }; + +const buildRes = () => { + const res = { + statusCode: null, + body: null, + json: fake((payload) => { + res.body = payload; + return res; + }), + status: fake((code) => { + res.statusCode = code; + return res; + }), + }; + return res; +}; + +const buildHandler = (overrides = {}) => ({ + getDevices: fake.resolves([thermostatDevice]), + createDevice: fake.resolves({ selector: 'created' }), + getSchedules: fake.resolves([{ selector: 'my-schedule' }]), + createSchedule: fake.resolves({ selector: 'new-schedule' }), + updateSchedule: fake.resolves({ selector: 'my-schedule' }), + deleteSchedule: fake.resolves(null), + setValue: fake.resolves(null), + setVariable: fake.resolves({ value: 'comfort' }), + getVariable: fake.resolves('comfort'), + broadcastConfigUpdated: fake.returns(null), + triggerApplySchedules: fake.returns(null), + ...overrides, +}); + +// Routes wrap their controller in asyncMiddleware, so go through it. +const callRoute = async (routes, route, req, res) => { + await routes[route].controller(req, res, (err) => { + if (err) { + throw err; + } + }); +}; + +describe('thermostat.controller', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should declare every route as authenticated', () => { + const routes = ThermostatController(buildHandler()); + + expect(Object.keys(routes)).to.have.lengthOf(10); + Object.values(routes).forEach((route) => { + expect(route.authenticated).to.equal(true); + }); + }); + + it('should return the thermostat devices with the search filters', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute( + routes, + 'get /api/v1/service/thermostat/device', + { query: { search: 'salon', order_dir: 'desc' } }, + res, + ); + + assert.calledWith(handler.getDevices, { search: 'salon', order_dir: 'desc' }); + expect(res.body).to.deep.equal([thermostatDevice]); + }); + + it('should create a device', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute(routes, 'post /api/v1/service/thermostat/device', { body: { name: 'Salon' } }, res); + + assert.calledWith(handler.createDevice, { name: 'Salon' }); + expect(res.body).to.deep.equal({ selector: 'created' }); + }); + + it('should return the schedules', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute(routes, 'get /api/v1/service/thermostat/schedule', {}, res); + + expect(res.body).to.deep.equal([{ selector: 'my-schedule' }]); + }); + + it('should create a schedule', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute(routes, 'post /api/v1/service/thermostat/schedule', { body: { name: 'Semaine' } }, res); + + assert.calledWith(handler.createSchedule, { name: 'Semaine' }); + expect(res.body).to.deep.equal({ selector: 'new-schedule' }); + }); + + it('should update a schedule', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute( + routes, + 'patch /api/v1/service/thermostat/schedule/:selector', + { params: { selector: 'my-schedule' }, body: { name: 'New' } }, + res, + ); + + assert.calledWith(handler.updateSchedule, 'my-schedule', { name: 'New' }); + expect(res.body).to.deep.equal({ selector: 'my-schedule' }); + }); + + it('should delete a schedule', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute( + routes, + 'delete /api/v1/service/thermostat/schedule/:selector', + { params: { selector: 'my-schedule' } }, + res, + ); + + assert.calledWith(handler.deleteSchedule, 'my-schedule'); + expect(res.body).to.deep.equal({ success: true }); + }); + + describe('setSetpoint', () => { + const route = 'post /api/v1/service/thermostat/setpoint/:feature_selector'; + + it('should set the setpoint through setValue', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute( + routes, + route, + { params: { feature_selector: 'thermostat-living-room' }, body: { value: '21.5' } }, + res, + ); + + assert.calledWith(handler.setValue, thermostatDevice, setpointFeature, 21.5); + expect(res.body).to.deep.equal({ success: true, value: 21.5 }); + }); + + it('should reject a non-numeric value', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute( + routes, + route, + { params: { feature_selector: 'thermostat-living-room' }, body: { value: 'hot' } }, + res, + ); + + expect(res.statusCode).to.equal(400); + expect(res.body).to.deep.equal({ error: 'INVALID_VALUE' }); + assert.notCalled(handler.setValue); + }); + + it('should refuse to write a feature that does not belong to this service', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute(routes, route, { params: { feature_selector: 'front-door-lock' }, body: { value: 1 } }, res); + + expect(res.statusCode).to.equal(404); + expect(res.body).to.deep.equal({ error: 'FEATURE_NOT_FOUND' }); + assert.notCalled(handler.setValue); + }); + + it('should refuse a thermostat feature that is not a setpoint', async () => { + const handler = buildHandler({ + getDevices: fake.resolves([ + { + selector: 'my-thermostat', + features: [ + { + selector: 'thermostat-mode', + category: DEVICE_FEATURE_CATEGORIES.THERMOSTAT, + type: 'mode', + }, + ], + }, + ]), + }); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute(routes, route, { params: { feature_selector: 'thermostat-mode' }, body: { value: 1 } }, res); + + expect(res.statusCode).to.equal(404); + assert.notCalled(handler.setValue); + }); + + it('should tolerate a device without features', async () => { + const handler = buildHandler({ getDevices: fake.resolves([{ selector: 'empty' }]) }); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute( + routes, + route, + { params: { feature_selector: 'thermostat-living-room' }, body: { value: 20 } }, + res, + ); + + expect(res.statusCode).to.equal(404); + }); + }); + + describe('setVariable', () => { + const route = 'post /api/v1/service/thermostat/state/:variable_key'; + + it('should set a THERMOSTAT_ variable', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute( + routes, + route, + { params: { variable_key: 'THERMOSTAT_X_PRESET' }, body: { value: 'comfort' } }, + res, + ); + + assert.calledWith(handler.setVariable, 'THERMOSTAT_X_PRESET', 'comfort'); + expect(res.body).to.deep.equal({ value: 'comfort' }); + }); + + it('should reject a configuration key: the config lives on the device', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute( + routes, + route, + { params: { variable_key: 'THERMOSTAT_CONFIG_LIVING_ROOM' }, body: { value: '{}' } }, + res, + ); + + expect(res.statusCode).to.equal(400); + assert.notCalled(handler.setVariable); + }); + + it('should reject a key outside the THERMOSTAT_ namespace', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute(routes, route, { params: { variable_key: 'SOME_OTHER_KEY' }, body: { value: 'x' } }, res); + + expect(res.statusCode).to.equal(400); + expect(res.body).to.deep.equal({ error: 'INVALID_VARIABLE_KEY' }); + assert.notCalled(handler.setVariable); + }); + + it('should reject a missing key', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute(routes, route, { params: {}, body: { value: 'x' } }, res); + + expect(res.statusCode).to.equal(400); + assert.notCalled(handler.setVariable); + }); + }); + + describe('getVariable', () => { + const route = 'get /api/v1/service/thermostat/state/:variable_key'; + + it('should return a runtime variable', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute(routes, route, { params: { variable_key: 'THERMOSTAT_X_PRESET' } }, res); + + assert.calledWith(handler.getVariable, 'THERMOSTAT_X_PRESET'); + expect(res.body).to.deep.equal({ value: 'comfort' }); + }); + + it('should return 404 when the variable is not set', async () => { + const handler = buildHandler({ getVariable: fake.resolves(null) }); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute(routes, route, { params: { variable_key: 'THERMOSTAT_X_PRESET' } }, res); + + expect(res.statusCode).to.equal(404); + expect(res.body).to.deep.equal({ error: 'VARIABLE_NOT_FOUND' }); + }); + + it('should reject a key outside the runtime namespace', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute(routes, route, { params: { variable_key: 'THERMOSTAT_CONFIG_X' } }, res); + + expect(res.statusCode).to.equal(400); + assert.notCalled(handler.getVariable); + }); + }); + + describe('applySchedules', () => { + const route = 'post /api/v1/service/thermostat/apply-schedules'; + + it('should trigger a regulation pass', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute(routes, route, { params: {}, body: {} }, res); + + assert.calledOnce(handler.triggerApplySchedules); + // The widgets must reload the config too, not just the heater re-regulate. + assert.calledOnce(handler.broadcastConfigUpdated); + expect(res.body).to.deep.equal({ success: true }); + }); + }); +}); diff --git a/server/test/services/thermostat/index.test.js b/server/test/services/thermostat/index.test.js new file mode 100644 index 0000000000..d1811405a4 --- /dev/null +++ b/server/test/services/thermostat/index.test.js @@ -0,0 +1,159 @@ +const { expect } = require('chai'); +const sinon = require('sinon').createSandbox(); +const proxyquire = require('proxyquire').noCallThru(); + +const { fake, assert } = sinon; + +const { EVENTS } = require('../../../utils/constants'); + +const buildService = ({ applySchedulesFails = false } = {}) => { + const handler = { + applySchedules: applySchedulesFails ? fake.rejects(new Error('boom')) : fake.resolves(null), + onDeviceNewState: fake.resolves(null), + applyTimer: null, + }; + const controllers = { 'get /api/v1/service/thermostat/device': {} }; + const ThermostatService = proxyquire('../../../services/thermostat/index', { + './lib': function ThermostatHandlerStub() { + return handler; + }, + './api/thermostat.controller': () => controllers, + '../../utils/logger': { + debug: fake.returns(null), + info: fake.returns(null), + warn: fake.returns(null), + }, + }); + const gladys = { + event: { + on: fake.returns(null), + removeListener: fake.returns(null), + }, + }; + return { service: ThermostatService(gladys, 'service-id'), gladys, handler, controllers }; +}; + +describe('ThermostatService', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should expose start, stop, the handler and the controllers', () => { + const { service, handler, controllers } = buildService(); + + expect(service).to.have.property('start'); + expect(service).to.have.property('stop'); + expect(service.device).to.equal(handler); + expect(service.controllers).to.equal(controllers); + }); + + it('should apply schedules immediately on start', async () => { + const { service, handler } = buildService(); + + await service.start(); + + assert.calledOnce(handler.applySchedules); + }); + + it('should listen to device new states', async () => { + const { service, gladys } = buildService(); + + await service.start(); + + assert.calledWith(gladys.event.on, EVENTS.DEVICE.NEW_STATE); + }); + + it('should forward a device new state to the handler', async () => { + const { service, gladys, handler } = buildService(); + + await service.start(); + const listener = gladys.event.on.firstCall.args[1]; + const event = { device_feature_external_id: 'window', state: 0 }; + listener(event); + + assert.calledWith(handler.onDeviceNewState, event); + }); + + it('should apply schedules every minute', async () => { + const clock = sinon.useFakeTimers(); + const { service, handler } = buildService(); + + await service.start(); + handler.applySchedules.resetHistory(); + await clock.tickAsync(60 * 1000); + + assert.calledOnce(handler.applySchedules); + }); + + it('should not let a failing startup pass prevent Gladys from starting', async () => { + const { service, handler } = buildService({ applySchedulesFails: true }); + + await service.start(); + // let the detached startup promise settle + await new Promise((resolve) => { + setImmediate(resolve); + }); + + assert.calledOnce(handler.applySchedules); + }); + + it('should clear the interval, the listener and the pending apply timer on stop', async () => { + const clock = sinon.useFakeTimers(); + const { service, gladys, handler } = buildService(); + + await service.start(); + handler.applyTimer = setTimeout(() => {}, 10000); + handler.applySchedules.resetHistory(); + await service.stop(); + await clock.tickAsync(60 * 1000); + + assert.calledWith(gladys.event.removeListener, EVENTS.DEVICE.NEW_STATE); + assert.notCalled(handler.applySchedules); + expect(handler.applyTimer).to.equal(null); + }); + + it('should be safe to stop a service that was never started', async () => { + const { service, gladys } = buildService(); + + await service.stop(); + + assert.notCalled(gladys.event.removeListener); + }); + + it('should not leave two regulation loops behind a second start', async () => { + const clock = sinon.useFakeTimers(); + const { service, handler } = buildService(); + + await service.start(); + await service.start(); + handler.applySchedules.resetHistory(); + await clock.tickAsync(60 * 1000); + + // One interval, not two: a duplicated loop would actuate the heaters twice. + assert.calledOnce(handler.applySchedules); + }); + + it('should drop the previous listener when started twice', async () => { + const { service, gladys } = buildService(); + + await service.start(); + const firstListener = gladys.event.on.firstCall.args[1]; + await service.start(); + + assert.calledWith(gladys.event.removeListener, EVENTS.DEVICE.NEW_STATE, firstListener); + expect(gladys.event.on.callCount).to.equal(2); + }); + + it('should stop cleanly after a restart', async () => { + const clock = sinon.useFakeTimers(); + const { service, handler } = buildService(); + + await service.start(); + await service.start(); + await service.stop(); + handler.applySchedules.resetHistory(); + await clock.tickAsync(60 * 1000); + + assert.notCalled(handler.applySchedules); + }); +}); diff --git a/server/test/services/thermostat/lib/index.test.js b/server/test/services/thermostat/lib/index.test.js new file mode 100644 index 0000000000..a1c82203ba --- /dev/null +++ b/server/test/services/thermostat/lib/index.test.js @@ -0,0 +1,39 @@ +const { expect } = require('chai'); + +const ThermostatHandler = require('../../../../services/thermostat/lib'); + +describe('ThermostatHandler', () => { + it('should keep the gladys instance and the service id', () => { + const gladys = { device: {} }; + + const handler = new ThermostatHandler(gladys, 'service-id'); + + expect(handler.gladys).to.equal(gladys); + expect(handler.serviceId).to.equal('service-id'); + }); + + it('should start with no pending apply timer', () => { + expect(new ThermostatHandler({}, 'service-id').applyTimer).to.equal(null); + }); + + it('should expose every operation of the integration', () => { + const handler = new ThermostatHandler({}, 'service-id'); + + [ + 'createDevice', + 'getDevices', + 'getSchedules', + 'createSchedule', + 'updateSchedule', + 'deleteSchedule', + 'applySchedules', + 'onDeviceNewState', + 'setValue', + 'postDelete', + 'setVariable', + 'triggerApplySchedules', + ].forEach((method) => { + expect(handler[method], `${method} should be defined`).to.be.a('function'); + }); + }); +}); diff --git a/server/test/services/thermostat/lib/thermostat.applySchedules.helpers.test.js b/server/test/services/thermostat/lib/thermostat.applySchedules.helpers.test.js new file mode 100644 index 0000000000..01322a23d8 --- /dev/null +++ b/server/test/services/thermostat/lib/thermostat.applySchedules.helpers.test.js @@ -0,0 +1,195 @@ +const { expect } = require('chai'); + +const { + parseEnd, + findMatchingPreset, + getSetpointForPreset, + computeSwitchActive, +} = require('../../../../services/thermostat/lib/thermostat.applySchedules'); + +describe('thermostat.applySchedules - parseEnd', () => { + it('should convert HH:MM to minutes', () => { + expect(parseEnd('08:30')).to.equal(510); + }); + + it('should treat 00:00 as end of day (1440)', () => { + expect(parseEnd('00:00')).to.equal(1440); + }); + + it('should handle 23:59', () => { + expect(parseEnd('23:59')).to.equal(1439); + }); +}); + +describe('thermostat.applySchedules - findMatchingPreset', () => { + it('should match a normal same-day slot', () => { + const today = [{ start_time: '08:00', end_time: '12:00', preset: 'comfort' }]; + expect(findMatchingPreset(today, [], 9 * 60)).to.equal('comfort'); + }); + + it('should return null when no slot matches', () => { + const today = [{ start_time: '08:00', end_time: '12:00', preset: 'comfort' }]; + expect(findMatchingPreset(today, [], 13 * 60)).to.equal(null); + }); + + it('should be inclusive of slot start and exclusive of slot end', () => { + const today = [{ start_time: '08:00', end_time: '12:00', preset: 'comfort' }]; + expect(findMatchingPreset(today, [], 8 * 60)).to.equal('comfort'); + expect(findMatchingPreset(today, [], 12 * 60)).to.equal(null); + }); + + it('should match a slot ending at midnight (00:00 = end of day)', () => { + const today = [{ start_time: '22:00', end_time: '00:00', preset: 'night' }]; + expect(findMatchingPreset(today, [], 23 * 60)).to.equal('night'); + }); + + it('should match the start portion of an overnight slot on the same day', () => { + // 22:00 -> 07:00 covers 22:00..23:59 on the start day + const today = [{ start_time: '22:00', end_time: '07:00', preset: 'night' }]; + expect(findMatchingPreset(today, [], 23 * 60)).to.equal('night'); + }); + + it('should match the tail of yesterday overnight slot after midnight', () => { + // yesterday 22:00 -> 07:00 covers 00:00..07:00 today + const yesterday = [{ start_time: '22:00', end_time: '07:00', preset: 'night' }]; + expect(findMatchingPreset([], yesterday, 5 * 60)).to.equal('night'); + }); + + it('should not match yesterday overnight slot after its end', () => { + const yesterday = [{ start_time: '22:00', end_time: '07:00', preset: 'night' }]; + expect(findMatchingPreset([], yesterday, 8 * 60)).to.equal(null); + }); + + it('should prefer a same-day normal slot over yesterday overnight', () => { + const today = [{ start_time: '06:00', end_time: '09:00', preset: 'comfort' }]; + const yesterday = [{ start_time: '22:00', end_time: '07:00', preset: 'night' }]; + expect(findMatchingPreset(today, yesterday, 6 * 60 + 30)).to.equal('comfort'); + }); +}); + +describe('thermostat.applySchedules - getSetpointForPreset', () => { + it('should return null for off preset', () => { + expect(getSetpointForPreset('off', {})).to.equal(null); + }); + + it('should return config value when present', () => { + expect(getSetpointForPreset('comfort', { preset_comfort: 21 })).to.equal(21); + }); + + it('should fall back to default when config missing the preset', () => { + expect(getSetpointForPreset('eco', {})).to.equal(18); + }); + + it('should fall back to default when config is null', () => { + expect(getSetpointForPreset('frost', null)).to.equal(7); + }); + + it('should coerce a string config value to number', () => { + expect(getSetpointForPreset('night', { preset_night: '19' })).to.equal(19); + }); + + it('should use generic 20 default for an unknown preset', () => { + expect(getSetpointForPreset('unknown', {})).to.equal(20); + }); + + it('should keep a legitimate 0 value instead of falling back to the default', () => { + expect(getSetpointForPreset('frost', { preset_frost: 0 })).to.equal(0); + expect(getSetpointForPreset('frost', { preset_frost: '0' })).to.equal(0); + }); +}); + +describe('thermostat.applySchedules - computeSwitchActive', () => { + const config = { hysteresis_start: 0.5, hysteresis_stop: 0.5 }; + + it('should return false when current temp is null', () => { + expect(computeSwitchActive(null, 20, 'heating', config, false)).to.equal(false); + }); + + describe('heating mode', () => { + it('should turn ON when temp is below setpoint minus hysteresis', () => { + expect(computeSwitchActive(19, 20, 'heating', config, false)).to.equal(true); + }); + + it('should turn OFF when temp is above setpoint plus hysteresis', () => { + expect(computeSwitchActive(21, 20, 'heating', config, true)).to.equal(false); + }); + + it('should keep current state (ON) in the neutral zone', () => { + expect(computeSwitchActive(20, 20, 'heating', config, true)).to.equal(true); + }); + + it('should keep current state (OFF) in the neutral zone', () => { + expect(computeSwitchActive(20, 20, 'heating', config, false)).to.equal(false); + }); + }); + + describe('cooling mode', () => { + it('should turn ON when temp is above setpoint plus hysteresis', () => { + expect(computeSwitchActive(25, 24, 'cooling', config, false)).to.equal(true); + }); + + it('should turn OFF when temp is below setpoint minus hysteresis', () => { + expect(computeSwitchActive(23, 24, 'cooling', config, true)).to.equal(false); + }); + + it('should keep current state in the neutral zone', () => { + expect(computeSwitchActive(24, 24, 'cooling', config, true)).to.equal(true); + }); + }); + + it('should use default hysteresis of 0.5 when config missing', () => { + expect(computeSwitchActive(19, 20, 'heating', {}, false)).to.equal(true); + expect(computeSwitchActive(19.6, 20, 'heating', {}, false)).to.equal(false); + }); + + it('should return false when setpoint is null', () => { + expect(computeSwitchActive(19, null, 'heating', config, true)).to.equal(false); + }); + + it('should honor a legitimate 0 hysteresis instead of falling back to 0.5', () => { + const zeroConfig = { hysteresis_start: 0, hysteresis_stop: 0 }; + // With 0 hysteresis, 19.9 < 20 - 0 → ON (with default 0.5 it would stay in the neutral zone) + expect(computeSwitchActive(19.9, 20, 'heating', zeroConfig, false)).to.equal(true); + }); + + describe('TPI control type', () => { + const tpiConfig = { control_type: 'tpi', tpi_cycle_time: 10, tpi_proportional_band: 2 }; + + it('should be fully ON when the error exceeds the proportional band', () => { + // error = 20 - 17 = 3 ≥ band 2 → always ON regardless of cycle position + expect(computeSwitchActive(17, 20, 'heating', tpiConfig, false, 0)).to.equal(true); + expect(computeSwitchActive(17, 20, 'heating', tpiConfig, false, 9 * 60000)).to.equal(true); + }); + + it('should be fully OFF when at or above the setpoint', () => { + expect(computeSwitchActive(20, 20, 'heating', tpiConfig, true, 0)).to.equal(false); + expect(computeSwitchActive(21, 20, 'heating', tpiConfig, true, 0)).to.equal(false); + }); + + it('should modulate within the cycle when inside the proportional band', () => { + // error = 1, band = 2 → ON 50% of a 10-minute cycle: minutes 0-4 ON, 5-9 OFF + expect(computeSwitchActive(19, 20, 'heating', tpiConfig, false, 0)).to.equal(true); + expect(computeSwitchActive(19, 20, 'heating', tpiConfig, false, 4 * 60000)).to.equal(true); + expect(computeSwitchActive(19, 20, 'heating', tpiConfig, false, 5 * 60000)).to.equal(false); + expect(computeSwitchActive(19, 20, 'heating', tpiConfig, false, 9 * 60000)).to.equal(false); + }); + + it('should fall back to hysteresis in cooling mode', () => { + // TPI is heating-only: a compressor cannot be pulsed over a cycle. + // 25 > 24 + 0.5 default start threshold → ON, and it stays ON for the + // whole cycle instead of modulating. + expect(computeSwitchActive(25, 24, 'cooling', tpiConfig, false, 0)).to.equal(true); + expect(computeSwitchActive(25, 24, 'cooling', tpiConfig, true, 5 * 60000)).to.equal(true); + // and below the stop threshold it turns off, as hysteresis does + expect(computeSwitchActive(23, 24, 'cooling', tpiConfig, true, 0)).to.equal(false); + }); + + it('should stay off when the computed on-time is under one minute', () => { + // Regulation runs once a minute: error = 0.1, band = 2 → 5% of a + // 10-minute cycle = 30 s, shorter than the control step. + expect(computeSwitchActive(19.9, 20, 'heating', tpiConfig, false, 0)).to.equal(false); + // error = 0.5 → 2.5 minutes, comfortably actionable + expect(computeSwitchActive(19.5, 20, 'heating', tpiConfig, false, 0)).to.equal(true); + }); + }); +}); diff --git a/server/test/services/thermostat/lib/thermostat.applySchedules.helpers2.test.js b/server/test/services/thermostat/lib/thermostat.applySchedules.helpers2.test.js new file mode 100644 index 0000000000..e94f62037f --- /dev/null +++ b/server/test/services/thermostat/lib/thermostat.applySchedules.helpers2.test.js @@ -0,0 +1,262 @@ +const { expect } = require('chai'); +const sinon = require('sinon').createSandbox(); +const proxyquire = require('proxyquire').noCallThru(); + +const { fake, assert } = sinon; + +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + SYSTEM_VARIABLE_NAMES, +} = require('../../../../utils/constants'); + +const load = () => + proxyquire('../../../../services/thermostat/lib/thermostat.applySchedules', { + '../../../models': { + ThermostatSchedule: { findOne: fake.resolves(null) }, + ThermostatScheduleSlot: {}, + }, + '../../../utils/logger': { + debug: fake.returns(null), + info: fake.returns(null), + warn: fake.returns(null), + }, + }); + +const { getThermostatFeature, phaseOffset, computeSwitchActive, applySchedules } = load(); + +const setpointFeature = { + selector: 'thermostat-living-room', + category: DEVICE_FEATURE_CATEGORIES.THERMOSTAT, + type: DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE, +}; + +describe('thermostat.getThermostatFeature', () => { + it('should find the setpoint feature whatever its position', () => { + const device = { features: [{ category: 'light', type: 'binary' }, setpointFeature] }; + + expect(getThermostatFeature(device)).to.equal(setpointFeature); + }); + + it('should ignore a thermostat feature of another type', () => { + const device = { + features: [{ category: DEVICE_FEATURE_CATEGORIES.THERMOSTAT, type: 'mode' }], + }; + + expect(getThermostatFeature(device)).to.equal(null); + }); + + it('should return null for a device without features', () => { + expect(getThermostatFeature({})).to.equal(null); + }); + + it('should return null when features is not an array', () => { + expect(getThermostatFeature({ features: 'nope' })).to.equal(null); + }); + + it('should return null for a missing device', () => { + expect(getThermostatFeature(null)).to.equal(null); + }); +}); + +describe('thermostat.phaseOffset', () => { + it('should stay within the cycle', () => { + const offset = phaseOffset('thermostat-living-room', 10); + + expect(offset).to.be.at.least(0); + expect(offset).to.be.below(10); + }); + + it('should be stable for the same key', () => { + expect(phaseOffset('thermostat-living-room', 10)).to.equal(phaseOffset('thermostat-living-room', 10)); + }); + + it('should spread two thermostats sharing a cycle time', () => { + const offsets = new Set( + ['thermostat-living-room', 'thermostat-bedroom', 'thermostat-office', 'thermostat-kitchen'].map((selector) => + phaseOffset(selector, 10), + ), + ); + + // Not every key can land on a distinct minute, but they must not all collide + expect(offsets.size).to.be.above(1); + }); + + it('should return 0 without a key', () => { + expect(phaseOffset('', 10)).to.equal(0); + expect(phaseOffset(null, 10)).to.equal(0); + }); + + it('should return 0 without a cycle time', () => { + expect(phaseOffset('thermostat-living-room', 0)).to.equal(0); + }); +}); + +describe('thermostat.computeSwitchActive - TPI', () => { + const tpiConfig = { control_type: 'tpi', tpi_cycle_time: 10, tpi_proportional_band: 2 }; + + it('should stay on for the whole cycle when the error exceeds the band', () => { + expect(computeSwitchActive(15, 21, 'heating', tpiConfig, false, 0, '')).to.equal(true); + }); + + it('should stay off when the required on-time is below one minute', () => { + // error 0.1 / band 2 → 0.5 minute over a 10 minute cycle + expect(computeSwitchActive(20.9, 21, 'heating', tpiConfig, false, 0, '')).to.equal(false); + }); + + it('should be on at the beginning of the cycle and off at its end', () => { + // error 1 / band 2 → 50% duty over 10 minutes, no phase offset + const early = computeSwitchActive(20, 21, 'heating', tpiConfig, false, 0, ''); + const late = computeSwitchActive(20, 21, 'heating', tpiConfig, false, 7 * 60000, ''); + + expect(early).to.equal(true); + expect(late).to.equal(false); + }); + + it('should shift the duty window with the phase key', () => { + const results = ['', 'thermostat-bedroom', 'thermostat-office', 'thermostat-kitchen'].map((key) => + computeSwitchActive(20, 21, 'heating', tpiConfig, false, 0, key), + ); + + expect(new Set(results).size).to.be.above(1); + }); + + it('should never go negative on the error', () => { + expect(computeSwitchActive(25, 21, 'heating', tpiConfig, false, 0, '')).to.equal(false); + }); + + it('should fall back to hysteresis for cooling', () => { + // A compressor cannot be pulsed: cooling ignores TPI + expect(computeSwitchActive(25, 21, 'cooling', tpiConfig, false, 0, '')).to.equal(true); + }); + + it('should use the default cycle and band when unset', () => { + expect(computeSwitchActive(15, 21, 'heating', { control_type: 'tpi' }, false, 0, '')).to.equal(true); + }); +}); + +describe('thermostat.computeSwitchActive - hysteresis', () => { + const config = { hysteresis_start: 0.5, hysteresis_stop: 0.5 }; + + it('should turn on when heating and the room is too cold', () => { + expect(computeSwitchActive(19, 21, 'heating', config, false)).to.equal(true); + }); + + it('should turn off when heating and the room is warm enough', () => { + expect(computeSwitchActive(22, 21, 'heating', config, true)).to.equal(false); + }); + + it('should hold the current state inside the neutral zone when heating', () => { + expect(computeSwitchActive(21, 21, 'heating', config, true)).to.equal(true); + expect(computeSwitchActive(21, 21, 'heating', config, false)).to.equal(false); + }); + + it('should turn on when cooling and the room is too hot', () => { + expect(computeSwitchActive(23, 21, 'cooling', config, false)).to.equal(true); + }); + + it('should turn off when cooling and the room is cold enough', () => { + expect(computeSwitchActive(19, 21, 'cooling', config, true)).to.equal(false); + }); + + it('should hold the current state inside the neutral zone when cooling', () => { + expect(computeSwitchActive(21, 21, 'cooling', config, true)).to.equal(true); + expect(computeSwitchActive(21, 21, 'cooling', config, false)).to.equal(false); + }); + + it('should use the default hysteresis without config', () => { + expect(computeSwitchActive(19, 21, 'heating', null, false)).to.equal(true); + }); + + it('should stay off without a temperature reading', () => { + expect(computeSwitchActive(null, 21, 'heating', config, true)).to.equal(false); + expect(computeSwitchActive(undefined, 21, 'heating', config, true)).to.equal(false); + }); + + it('should stay off without a setpoint', () => { + expect(computeSwitchActive(19, null, 'heating', config, true)).to.equal(false); + expect(computeSwitchActive(19, undefined, 'heating', config, true)).to.equal(false); + }); +}); + +describe('thermostat.applySchedules', () => { + const buildContext = (devices, timezone) => ({ + gladys: { + device: { + get: fake((query) => { + if (query && query.service === 'thermostat') { + return Promise.resolve(devices); + } + return Promise.resolve([]); + }), + setValue: fake.resolves(null), + saveState: fake.resolves(null), + }, + variable: { + getValue: fake((key) => Promise.resolve(key === SYSTEM_VARIABLE_NAMES.TIMEZONE ? timezone || null : null)), + setValue: fake.resolves(null), + }, + event: { emit: fake.returns(null) }, + }, + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should do nothing when no thermostat device exists', async () => { + const ctx = buildContext([]); + + await applySchedules.call(ctx); + + assert.notCalled(ctx.gladys.device.setValue); + }); + + it('should do nothing when device.get returns nothing', async () => { + const ctx = buildContext(null); + + await applySchedules.call(ctx); + + assert.notCalled(ctx.gladys.device.setValue); + }); + + it('should read the schedule clock in the configured timezone', async () => { + const ctx = buildContext([{ features: [setpointFeature], params: [] }], 'America/New_York'); + + await applySchedules.call(ctx); + + assert.calledWith(ctx.gladys.variable.getValue, SYSTEM_VARIABLE_NAMES.TIMEZONE); + }); + + it('should fall back to a default timezone when the variable is unreadable', async () => { + const ctx = buildContext([{ features: [setpointFeature], params: [] }]); + ctx.gladys.variable.getValue = fake.rejects(new Error('no variable table')); + + await applySchedules.call(ctx); + + assert.notCalled(ctx.gladys.device.setValue); + }); + + it('should keep regulating the other devices when one fails', async () => { + const failing = { + get features() { + throw new Error('broken device'); + }, + }; + const ctx = buildContext([failing, { features: [setpointFeature], params: [] }]); + + await applySchedules.call(ctx); + + // Reaching here without throwing is the assertion: one bad device is isolated + assert.called(ctx.gladys.device.get); + }); + + it('should swallow a failure while loading the devices', async () => { + const ctx = buildContext([]); + ctx.gladys.device.get = fake.rejects(new Error('db down')); + + await applySchedules.call(ctx); + + assert.notCalled(ctx.gladys.device.setValue); + }); +}); diff --git a/server/test/services/thermostat/lib/thermostat.applySchedules.test.js b/server/test/services/thermostat/lib/thermostat.applySchedules.test.js new file mode 100644 index 0000000000..3913808ff6 --- /dev/null +++ b/server/test/services/thermostat/lib/thermostat.applySchedules.test.js @@ -0,0 +1,345 @@ +const { expect } = require('chai'); +const sinon = require('sinon').createSandbox(); +const proxyquire = require('proxyquire').noCallThru(); + +const { fake } = sinon; + +const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../../utils/constants'); + +// The regulation loop resolves the setpoint feature by category and type, +// never by position in the features array. +const thermostatFeature = (extra = {}) => ({ + selector: 'thermostat-living-room', + category: DEVICE_FEATURE_CATEGORIES.THERMOSTAT, + type: DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE, + ...extra, +}); + +// Build a slot covering the whole day so the current time always matches. +const fullDaySlot = (preset) => ({ + day_of_week: 0, + start_time: '00:00', + end_time: '00:00', // 00:00 end = end of day (1440) + preset, +}); + +// Schedule slots use the real "today" day-of-week so findMatchingPreset matches now. +// Regulation reads the clock in the Gladys timezone, which falls back to +// Europe/Paris when the TIMEZONE variable is unset, as it is in these fixtures. +const { getCurrentDayAndMinutes } = require('../../../../utils/thermostatSchedule'); + +const todayDow = getCurrentDayAndMinutes(new Date(), 'Europe/Paris').dayOfWeek; + +const buildSchedule = (preset) => ({ + selector: 'my-schedule', + slots: [{ ...fullDaySlot(preset), day_of_week: todayDow }], +}); + +const buildDb = (schedule) => ({ + ThermostatSchedule: { + findOne: fake.resolves(schedule), + }, + ThermostatScheduleSlot: {}, +}); + +const loadModule = (schedule) => + proxyquire('../../../../services/thermostat/lib/thermostat.applySchedules', { + '../../../models': buildDb(schedule), + '../../../utils/logger': { + debug: fake.returns(null), + info: fake.returns(null), + warn: fake.returns(null), + }, + }); + +// Helper to build a thermostat device with params-based config. +const buildThermostatDevice = (params) => ({ + features: [thermostatFeature()], + params, +}); + +const baseParams = (overrides = {}) => { + const map = { + THERMOSTAT_TEMPERATURE_FEATURE: 'temp-sensor', + THERMOSTAT_SWITCH_FEATURE: 'heater-switch', + // The active schedule is device-owned since the config moved off the dashboard. + THERMOSTAT_ACTIVE_SCHEDULE: 'my-schedule', + THERMOSTAT_MODE: 'heating', + THERMOSTAT_HYSTERESIS_START: '0.5', + THERMOSTAT_HYSTERESIS_STOP: '0.5', + THERMOSTAT_PRESET_COMFORT: '21', + ...overrides, + }; + return Object.keys(map).map((name) => ({ name, value: map[name] })); +}; + +describe('thermostat.applySchedules (integration)', () => { + let gladysDeviceSetValue; + let gladysVariableSetValue; + let eventEmit; + + const makeGladys = ({ devices, variables, switchOn, currentTemp, windowOpen = false }) => { + gladysDeviceSetValue = fake.resolves(null); + gladysVariableSetValue = fake.resolves(null); + eventEmit = fake.returns(null); + + const switchDevice = { + features: [{ selector: 'heater-switch', last_value: switchOn ? 1 : 0 }], + }; + const tempDevice = { + features: [{ selector: 'temp-sensor', last_value: currentTemp }], + }; + // Window contact: last_value 0 = open (cuts heating), 1 = closed. + const windowDevice = { + features: [{ selector: 'window-sensor', last_value: windowOpen ? 0 : 1 }], + }; + + return { + device: { + get: fake((query) => { + if (query && query.service === 'thermostat') { + return Promise.resolve(devices); + } + if (query && query.device_feature_selectors === 'heater-switch') { + return Promise.resolve([switchDevice]); + } + if (query && query.device_feature_selectors === 'temp-sensor') { + return Promise.resolve([tempDevice]); + } + if (query && query.device_feature_selectors === 'window-sensor') { + return Promise.resolve([windowDevice]); + } + return Promise.resolve([]); + }), + setValue: gladysDeviceSetValue, + saveState: fake.resolves(null), + }, + variable: { + getValue: fake((key) => Promise.resolve((variables && variables[key]) || null)), + setValue: gladysVariableSetValue, + }, + event: { emit: eventEmit }, + }; + }; + + beforeEach(() => { + sinon.reset(); + }); + + it('should do nothing when there are no thermostat devices', async () => { + const mod = loadModule(buildSchedule('comfort')); + const gladys = makeGladys({ devices: [], variables: {}, switchOn: false, currentTemp: 18 }); + const ctx = { gladys, serviceId: 'svc' }; + await mod.applySchedules.call(ctx); + expect(gladysDeviceSetValue.called).to.equal(false); + }); + + it('should turn switch ON when heating and temp is below setpoint', async () => { + const device = buildThermostatDevice(baseParams({ THERMOSTAT_PRESET_COMFORT: '21' })); + const mod = loadModule(buildSchedule('comfort')); + const gladys = makeGladys({ + devices: [device], + variables: { THERMOSTAT_ACTIVE_SCHEDULE_THERMOSTAT_LIVING_ROOM: 'my-schedule' }, + switchOn: false, + currentTemp: 18, + }); + const ctx = { gladys, serviceId: 'svc' }; + await mod.applySchedules.call(ctx); + expect(gladysDeviceSetValue.calledOnce).to.equal(true); + const [, , value] = gladysDeviceSetValue.firstCall.args; + expect(value).to.equal(1); + }); + + it('should turn switch OFF when heating and temp is above setpoint', async () => { + const device = buildThermostatDevice(baseParams({ THERMOSTAT_PRESET_COMFORT: '21' })); + const mod = loadModule(buildSchedule('comfort')); + const gladys = makeGladys({ + devices: [device], + variables: { THERMOSTAT_ACTIVE_SCHEDULE_THERMOSTAT_LIVING_ROOM: 'my-schedule' }, + switchOn: true, + currentTemp: 23, + }); + const ctx = { gladys, serviceId: 'svc' }; + await mod.applySchedules.call(ctx); + expect(gladysDeviceSetValue.calledOnce).to.equal(true); + const [, , value] = gladysDeviceSetValue.firstCall.args; + expect(value).to.equal(0); + }); + + it('should not change the switch when already at the desired state', async () => { + const device = buildThermostatDevice(baseParams({ THERMOSTAT_PRESET_COMFORT: '21' })); + const mod = loadModule(buildSchedule('comfort')); + const gladys = makeGladys({ + devices: [device], + variables: { THERMOSTAT_ACTIVE_SCHEDULE_THERMOSTAT_LIVING_ROOM: 'my-schedule' }, + switchOn: true, + currentTemp: 18, // heating, below setpoint → should be ON, already ON + }); + const ctx = { gladys, serviceId: 'svc' }; + await mod.applySchedules.call(ctx); + expect(gladysDeviceSetValue.called).to.equal(false); + }); + + it('should turn switch OFF when matched preset is off', async () => { + const device = buildThermostatDevice(baseParams()); + const mod = loadModule(buildSchedule('off')); + const gladys = makeGladys({ + devices: [device], + variables: { THERMOSTAT_ACTIVE_SCHEDULE_THERMOSTAT_LIVING_ROOM: 'my-schedule' }, + switchOn: true, + currentTemp: 18, + }); + const ctx = { gladys, serviceId: 'svc' }; + await mod.applySchedules.call(ctx); + expect(gladysDeviceSetValue.calledOnce).to.equal(true); + const [, , value] = gladysDeviceSetValue.firstCall.args; + expect(value).to.equal(0); + }); + + it('should do nothing when there is no schedule and no preset selected', async () => { + const device = buildThermostatDevice(baseParams({ THERMOSTAT_ACTIVE_SCHEDULE: '' })); + const mod = loadModule(buildSchedule('comfort')); + const gladys = makeGladys({ + devices: [device], + variables: {}, + switchOn: false, + currentTemp: 18, + }); + const ctx = { gladys, serviceId: 'svc' }; + await mod.applySchedules.call(ctx); + expect(gladysDeviceSetValue.called).to.equal(false); + }); + + it('should regulate on the current preset when no schedule is configured', async () => { + const device = buildThermostatDevice(baseParams({ THERMOSTAT_PRESET_COMFORT: '21' })); + const mod = loadModule(null); + const gladys = makeGladys({ + devices: [device], + variables: { THERMOSTAT_THERMOSTAT_LIVING_ROOM_PRESET: 'comfort' }, + switchOn: false, + currentTemp: 18, // cold → heating must turn ON even without a schedule + }); + const ctx = { gladys, serviceId: 'svc' }; + await mod.applySchedules.call(ctx); + expect(gladysDeviceSetValue.calledOnce).to.equal(true); + const [, , value] = gladysDeviceSetValue.firstCall.args; + expect(value).to.equal(1); + }); + + it('should not write the setpoint state when it did not change', async () => { + const device = { + features: [{ selector: 'thermostat-living-room', last_value: 21 }], + params: baseParams({ THERMOSTAT_PRESET_COMFORT: '21' }), + }; + const mod = loadModule(buildSchedule('comfort')); + const gladys = makeGladys({ + devices: [device], + variables: { + THERMOSTAT_ACTIVE_SCHEDULE_THERMOSTAT_LIVING_ROOM: 'my-schedule', + THERMOSTAT_THERMOSTAT_LIVING_ROOM_PRESET: 'comfort', + }, + switchOn: true, + currentTemp: 18, // already ON, stays ON + }); + const ctx = { gladys, serviceId: 'svc' }; + await mod.applySchedules.call(ctx); + // Setpoint unchanged (21) and preset unchanged → no state write, no preset write, no emit + expect(gladys.device.saveState.called).to.equal(false); + const presetWrites = gladysVariableSetValue + .getCalls() + .filter((c) => c.args[0] === 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_PRESET'); + expect(presetWrites).to.have.lengthOf(0); + expect(eventEmit.called).to.equal(false); + }); + + it('should cut heating when the window is open', async () => { + const device = buildThermostatDevice(baseParams({ THERMOSTAT_WINDOW_FEATURE: 'window-sensor' })); + const mod = loadModule(buildSchedule('comfort')); + const gladys = makeGladys({ + devices: [device], + variables: { THERMOSTAT_ACTIVE_SCHEDULE_THERMOSTAT_LIVING_ROOM: 'my-schedule' }, + switchOn: true, + currentTemp: 18, // cold, would normally heat + windowOpen: true, + }); + const ctx = { gladys, serviceId: 'svc' }; + await mod.applySchedules.call(ctx); + // The only setValue call should turn the switch OFF (window override). + expect(gladysDeviceSetValue.calledOnce).to.equal(true); + const [, , value] = gladysDeviceSetValue.firstCall.args; + expect(value).to.equal(0); + }); + + it('should not apply the schedule preset when manual mode is active', async () => { + const device = buildThermostatDevice(baseParams()); + const mod = loadModule(buildSchedule('comfort')); + const gladys = makeGladys({ + devices: [device], + variables: { + THERMOSTAT_ACTIVE_SCHEDULE_THERMOSTAT_LIVING_ROOM: 'my-schedule', + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_MODE: 'true', + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_SETPOINT: JSON.stringify({ setpoint: 22 }), + }, + switchOn: false, + currentTemp: 18, + }); + const ctx = { gladys, serviceId: 'svc' }; + await mod.applySchedules.call(ctx); + // The schedule preset variable must NOT be written while manual mode is active. + const presetWrites = gladysVariableSetValue + .getCalls() + .filter((c) => c.args[0] === 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_PRESET'); + expect(presetWrites).to.have.lengthOf(0); + }); + + it('should revert manual mode and apply schedule when the manual timer expired', async () => { + const device = buildThermostatDevice(baseParams()); + const mod = loadModule(buildSchedule('comfort')); + const gladys = makeGladys({ + devices: [device], + variables: { + THERMOSTAT_ACTIVE_SCHEDULE_THERMOSTAT_LIVING_ROOM: 'my-schedule', + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_MODE: 'true', + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL: String(Date.now() - 1000), + }, + switchOn: false, + currentTemp: 18, + }); + const ctx = { gladys, serviceId: 'svc' }; + await mod.applySchedules.call(ctx); + // Manual mode should be turned off. + const manualOff = gladysVariableSetValue + .getCalls() + .filter((c) => c.args[0] === 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_MODE' && c.args[1] === 'false'); + expect(manualOff).to.have.lengthOf(1); + // And the heating switch should be actuated by the schedule (ON, since cold). + expect(gladysDeviceSetValue.called).to.equal(true); + }); + + it('should push the schedule preset back when the manual timer expires on the same preset', async () => { + // Reproduces the reported bug: the schedule says "away" all day, the user + // raises the temperature by hand (dashboards show "comfort"), and the timer + // expires. The stored preset never left "away", so notifying only on change + // left every open dashboard stuck on "comfort" until a page refresh. + const device = buildThermostatDevice(baseParams()); + const mod = loadModule(buildSchedule('away')); + const gladys = makeGladys({ + devices: [device], + variables: { + THERMOSTAT_ACTIVE_SCHEDULE_THERMOSTAT_LIVING_ROOM: 'my-schedule', + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_MODE: 'true', + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL: String(Date.now() - 1000), + THERMOSTAT_THERMOSTAT_LIVING_ROOM_PRESET: 'away', + }, + switchOn: false, + currentTemp: 18, + }); + const ctx = { gladys, serviceId: 'svc' }; + await mod.applySchedules.call(ctx); + + const presetEvents = eventEmit + .getCalls() + .filter((c) => c.args[1] && c.args[1].type === 'thermostat.preset-updated' && c.args[1].payload.value === 'away'); + expect(presetEvents).to.have.lengthOf(1); + }); +}); diff --git a/server/test/services/thermostat/lib/thermostat.deviceConfig.test.js b/server/test/services/thermostat/lib/thermostat.deviceConfig.test.js new file mode 100644 index 0000000000..689bbf8284 --- /dev/null +++ b/server/test/services/thermostat/lib/thermostat.deviceConfig.test.js @@ -0,0 +1,136 @@ +const { expect } = require('chai'); +const sinon = require('sinon').createSandbox(); + +const { fake } = sinon; + +const { + toNumber, + buildParamsConfig, + getDeviceConfig, + getFeatureBySelector, +} = require('../../../../services/thermostat/lib/thermostat.deviceConfig'); + +const deviceWithParams = (params) => ({ + params: Object.entries(params).map(([name, value]) => ({ name, value })), +}); + +describe('thermostat.deviceConfig - toNumber', () => { + it('should parse a numeric string', () => { + expect(toNumber('21.5', 0)).to.equal(21.5); + }); + + it('should keep a legitimate 0 instead of falling back', () => { + expect(toNumber('0', 7)).to.equal(0); + }); + + it('should fall back on a non-numeric value', () => { + expect(toNumber('abc', 7)).to.equal(7); + expect(toNumber(null, 7)).to.equal(7); + expect(toNumber(undefined, 7)).to.equal(7); + }); +}); + +describe('thermostat.deviceConfig - buildParamsConfig', () => { + it('should return null when the device has no params', () => { + expect(buildParamsConfig({ params: [] })).to.equal(null); + expect(buildParamsConfig({})).to.equal(null); + }); + + it('should read features and mode from params', () => { + const config = buildParamsConfig( + deviceWithParams({ + THERMOSTAT_TEMPERATURE_FEATURE: 'temp-sensor', + THERMOSTAT_SWITCH_FEATURE: 'heater-switch', + THERMOSTAT_MODE: 'cooling', + THERMOSTAT_CONTROL_TYPE: 'tpi', + }), + ); + + expect(config.temperature_feature).to.equal('temp-sensor'); + expect(config.switch_feature).to.equal('heater-switch'); + expect(config.default_mode).to.equal('cooling'); + expect(config.control_type).to.equal('tpi'); + }); + + it('should apply defaults for missing params', () => { + const config = buildParamsConfig(deviceWithParams({ THERMOSTAT_TEMPERATURE_FEATURE: 'temp' })); + + expect(config.default_mode).to.equal('heating'); + expect(config.control_type).to.equal('hysteresis'); + expect(config.humidity_feature).to.equal(null); + expect(config.preset_frost).to.equal(7); + expect(config.preset_away).to.equal(16); + }); + + it('should keep a preset explicitly set to 0', () => { + const config = buildParamsConfig( + deviceWithParams({ THERMOSTAT_TEMPERATURE_FEATURE: 'temp', THERMOSTAT_PRESET_FROST: '0' }), + ); + + expect(config.preset_frost).to.equal(0); + }); +}); + +describe('thermostat.deviceConfig - getDeviceConfig', () => { + it('should build the config from the device params', () => { + const device = deviceWithParams({ THERMOSTAT_TEMPERATURE_FEATURE: 'temp-sensor' }); + + const config = getDeviceConfig(device); + + expect(config.temperature_feature).to.equal('temp-sensor'); + }); + + it('should return null when the device carries no params', () => { + // No legacy variable is consulted: the device is the only store, so a + // thermostat without params simply has no configuration. + expect(getDeviceConfig({ params: [] })).to.equal(null); + }); + + it('should expose the min/max, unit and manual duration params', () => { + const config = getDeviceConfig( + deviceWithParams({ + THERMOSTAT_MIN_TEMP: '10', + THERMOSTAT_MAX_TEMP: '28', + THERMOSTAT_TEMP_UNIT: 'F', + THERMOSTAT_MANUAL_DURATION: '45', + }), + ); + + expect(config.temp_min).to.equal(10); + expect(config.temp_max).to.equal(28); + expect(config.temp_unit).to.equal('F'); + expect(config.manual_duration).to.equal(45); + }); + + it('should default the min/max, unit and manual duration when unset', () => { + const config = getDeviceConfig(deviceWithParams({ THERMOSTAT_TEMPERATURE_FEATURE: 'temp' })); + + expect(config.temp_min).to.equal(5); + expect(config.temp_max).to.equal(35); + expect(config.temp_unit).to.equal('C'); + expect(config.manual_duration).to.equal(30); + }); +}); + +describe('thermostat.deviceConfig - getFeatureBySelector', () => { + it('should return the device and its matching feature', async () => { + const feature = { selector: 'heater-switch' }; + const gladys = { device: { get: fake.resolves([{ features: [feature] }]) } }; + + const found = await getFeatureBySelector(gladys, 'heater-switch'); + + expect(found.feature).to.equal(feature); + }); + + it('should return null when no device matches', async () => { + const gladys = { device: { get: fake.resolves([]) } }; + + expect(await getFeatureBySelector(gladys, 'unknown')).to.equal(null); + }); + + it('should return null when the device has no matching feature', async () => { + const gladys = { device: { get: fake.resolves([{ features: [{ selector: 'other' }] }]) } }; + + expect(await getFeatureBySelector(gladys, 'heater-switch')).to.equal(null); + }); +}); diff --git a/server/test/services/thermostat/lib/thermostat.devices.test.js b/server/test/services/thermostat/lib/thermostat.devices.test.js new file mode 100644 index 0000000000..b59af6f568 --- /dev/null +++ b/server/test/services/thermostat/lib/thermostat.devices.test.js @@ -0,0 +1,260 @@ +const { expect } = require('chai'); +const sinon = require('sinon').createSandbox(); +const proxyquire = require('proxyquire').noCallThru(); + +const { fake, assert } = sinon; + +const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../../utils/constants'); + +const stubLogger = { + '../../../utils/logger': { + debug: fake.returns(null), + info: fake.returns(null), + warn: fake.returns(null), + }, +}; + +const { createDevice } = proxyquire('../../../../services/thermostat/lib/thermostat.createDevice', stubLogger); +const { getDevices } = proxyquire('../../../../services/thermostat/lib/thermostat.getDevices', stubLogger); +const { postDelete } = proxyquire('../../../../services/thermostat/lib/thermostat.postDelete', stubLogger); + +const setpointFeature = { + name: 'Thermostat Salon', + external_id: 'thermostat:salon:target-temperature', + category: DEVICE_FEATURE_CATEGORIES.THERMOSTAT, + type: DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE, +}; + +describe('thermostat.getDevices', () => { + it('should only ask for the devices of this service', async () => { + const handler = { gladys: { device: { get: fake.resolves([]) } }, getDevices }; + + await handler.getDevices(); + + assert.calledWith(handler.gladys.device.get, { service: 'thermostat' }); + }); + + it('should forward the search and order filters', async () => { + const handler = { gladys: { device: { get: fake.resolves([]) } }, getDevices }; + + await handler.getDevices({ search: 'salon', order_dir: 'desc' }); + + assert.calledWith(handler.gladys.device.get, { service: 'thermostat', search: 'salon', order_dir: 'desc' }); + }); + + it('should leave out an empty search or order', async () => { + const handler = { gladys: { device: { get: fake.resolves([]) } }, getDevices }; + + await handler.getDevices({ search: '', order_dir: '' }); + + assert.calledWith(handler.gladys.device.get, { service: 'thermostat' }); + }); + + it('should return the devices found', async () => { + const devices = [{ selector: 'my-thermostat' }]; + const handler = { gladys: { device: { get: fake.resolves(devices) } }, getDevices }; + + expect(await handler.getDevices()).to.deep.equal(devices); + }); +}); + +describe('thermostat.createDevice', () => { + const buildHandler = () => ({ + gladys: { device: { create: fake((device) => Promise.resolve(device)) } }, + serviceId: 'service-id', + invalidateWindowCache: fake.returns(null), + createDevice, + }); + + it('should create the device on this service', async () => { + const handler = buildHandler(); + + const created = await handler.createDevice({ name: 'Salon', features: [setpointFeature] }); + + expect(created.service_id).to.equal('service-id'); + expect(created.name).to.equal('Salon'); + }); + + it('should refuse a device without a setpoint feature', async () => { + const handler = buildHandler(); + + let error = null; + try { + await handler.createDevice({ name: 'Salon', features: [] }); + } catch (e) { + error = e; + } + + expect(error).to.not.equal(null); + expect(error.message).to.contain('target-temperature'); + assert.notCalled(handler.gladys.device.create); + }); + + it('should drop features that are not a thermostat setpoint', async () => { + const handler = buildHandler(); + + const created = await handler.createDevice({ + name: 'Salon', + features: [{ category: 'light', type: 'binary' }, setpointFeature], + }); + + expect(created.features).to.deep.equal([setpointFeature]); + }); + + it('should keep a single setpoint feature', async () => { + const handler = buildHandler(); + + const created = await handler.createDevice({ + name: 'Salon', + features: [setpointFeature, { ...setpointFeature, external_id: 'second' }], + }); + + expect(created.features).to.have.lengthOf(1); + }); + + it('should drop params outside the thermostat namespace', async () => { + const handler = buildHandler(); + + const created = await handler.createDevice({ + name: 'Salon', + features: [setpointFeature], + params: [ + { name: 'THERMOSTAT_SWITCH_FEATURE', value: 'heater-switch' }, + { name: 'SOMETHING_ELSE', value: 'nope' }, + ], + }); + + expect(created.params).to.deep.equal([{ name: 'THERMOSTAT_SWITCH_FEATURE', value: 'heater-switch' }]); + }); + + it('should not forward unknown top-level fields', async () => { + const handler = buildHandler(); + + const created = await handler.createDevice({ + name: 'Salon', + features: [setpointFeature], + activeSchedule: 'my-schedule', + }); + + expect(created).to.not.have.property('activeSchedule'); + }); + + it('should tolerate a device without params', async () => { + const handler = buildHandler(); + + const created = await handler.createDevice({ name: 'Salon', features: [setpointFeature] }); + + expect(created.params).to.deep.equal([]); + }); + + it('should keep the min/max, unit and manual duration params the form owns', async () => { + const handler = buildHandler(); + + const created = await handler.createDevice({ + name: 'Salon', + features: [setpointFeature], + params: [ + { name: 'THERMOSTAT_MIN_TEMP', value: '5' }, + { name: 'THERMOSTAT_MAX_TEMP', value: '35' }, + { name: 'THERMOSTAT_TEMP_UNIT', value: 'C' }, + { name: 'THERMOSTAT_MANUAL_DURATION', value: '45' }, + ], + }); + + expect(created.params.map((param) => param.name)).to.have.members([ + 'THERMOSTAT_MIN_TEMP', + 'THERMOSTAT_MAX_TEMP', + 'THERMOSTAT_TEMP_UNIT', + 'THERMOSTAT_MANUAL_DURATION', + ]); + }); + + it('should drop the cached window selectors', async () => { + const handler = buildHandler(); + + await handler.createDevice({ name: 'Salon', features: [setpointFeature] }); + + assert.calledOnce(handler.invalidateWindowCache); + }); +}); + +describe('thermostat.postDelete', () => { + const buildHandler = (destroy) => ({ + gladys: { variable: { destroy } }, + serviceId: 'service-id', + invalidateWindowCache: fake.returns(null), + postDelete, + }); + + it('should remove every runtime variable of the deleted features', async () => { + const handler = buildHandler(fake.resolves(null)); + + await handler.postDelete({ features: [{ selector: 'thermostat-living-room' }] }); + + const keys = handler.gladys.variable.destroy.getCalls().map((call) => call.args[0]); + // The configuration is not in this list: it lives on the device row, which + // is deleted with the device itself. + expect(keys).to.have.members([ + 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_PRESET', + 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_PRESET_FALLBACK', + 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_MODE', + 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL', + 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_SETPOINT', + ]); + }); + + it('should remove the variables in this service scope', async () => { + const handler = buildHandler(fake.resolves(null)); + + await handler.postDelete({ features: [{ selector: 'thermostat-living-room' }] }); + + handler.gladys.variable.destroy.getCalls().forEach((call) => { + expect(call.args[1]).to.equal('service-id'); + }); + }); + + it('should swallow a variable that cannot be removed', async () => { + const handler = buildHandler(fake.rejects(new Error('gone'))); + + await handler.postDelete({ features: [{ selector: 'thermostat-living-room' }] }); + + assert.called(handler.gladys.variable.destroy); + }); + + it('should do nothing for a device without features', async () => { + const handler = buildHandler(fake.resolves(null)); + + await handler.postDelete({}); + + assert.notCalled(handler.gladys.variable.destroy); + }); + + it('should do nothing when no device is given', async () => { + const handler = buildHandler(fake.resolves(null)); + + await handler.postDelete(undefined); + + assert.notCalled(handler.gladys.variable.destroy); + }); +}); + +describe('thermostat.createDevice - defensive paths', () => { + it('should refuse a device with no features field at all', async () => { + const handler = { + gladys: { device: { create: fake.resolves(null) } }, + serviceId: 'service-id', + invalidateWindowCache: fake.returns(null), + createDevice, + }; + + let error = null; + try { + await handler.createDevice({ name: 'Salon' }); + } catch (e) { + error = e; + } + + expect(error).to.not.equal(null); + assert.notCalled(handler.gladys.device.create); + }); +}); diff --git a/server/test/services/thermostat/lib/thermostat.onWindowOpen.test.js b/server/test/services/thermostat/lib/thermostat.onWindowOpen.test.js new file mode 100644 index 0000000000..016af8525f --- /dev/null +++ b/server/test/services/thermostat/lib/thermostat.onWindowOpen.test.js @@ -0,0 +1,517 @@ +const { expect } = require('chai'); +const sinon = require('sinon').createSandbox(); +const proxyquire = require('proxyquire').noCallThru(); + +const { fake } = sinon; + +const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../../utils/constants'); + +const loadModule = () => + proxyquire('../../../../services/thermostat/lib/thermostat.onWindowOpen', { + '../../../utils/logger': { + debug: fake.returns(null), + info: fake.returns(null), + warn: fake.returns(null), + }, + }); + +const buildGladys = ({ switchOn = true } = {}) => { + const switchDevice = { + features: [{ selector: 'heater-switch', last_value: switchOn ? 1 : 0 }], + }; + const thermostatDevice = { + features: [ + { + selector: 'thermostat-living-room', + category: DEVICE_FEATURE_CATEGORIES.THERMOSTAT, + type: DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE, + }, + ], + params: [ + { name: 'THERMOSTAT_WINDOW_FEATURE', value: 'window-sensor' }, + { name: 'THERMOSTAT_SWITCH_FEATURE', value: 'heater-switch' }, + ], + }; + const setValue = fake.resolves(null); + return { + gladys: { + device: { + get: fake((query) => { + if (query && query.service === 'thermostat') { + return Promise.resolve([thermostatDevice]); + } + if (query && query.device_feature_selectors === 'heater-switch') { + return Promise.resolve([switchDevice]); + } + return Promise.resolve([]); + }), + setValue, + }, + stateManager: { + get: fake((type, externalId) => { + if (type === 'deviceFeatureByExternalId' && externalId === 'zigbee2mqtt:window:contact') { + return { selector: 'window-sensor' }; + } + return null; + }), + }, + }, + setValue, + }; +}; + +describe('thermostat.onDeviceNewState (window open)', () => { + beforeEach(() => { + sinon.reset(); + }); + + it('should cut the switch on the service event shape { device_feature_external_id, state }', async () => { + const mod = loadModule(); + const { gladys, setValue } = buildGladys({ switchOn: true }); + await mod.onDeviceNewState.call( + { gladys }, + { + device_feature_external_id: 'zigbee2mqtt:window:contact', + state: 0, + }, + ); + expect(setValue.calledOnce).to.equal(true); + expect(setValue.firstCall.args[2]).to.equal(0); + }); + + it('should cut the switch on the legacy event shape { device_feature, last_value }', async () => { + const mod = loadModule(); + const { gladys, setValue } = buildGladys({ switchOn: true }); + await mod.onDeviceNewState.call( + { gladys }, + { + device_feature: 'window-sensor', + last_value: 0, + }, + ); + expect(setValue.calledOnce).to.equal(true); + expect(setValue.firstCall.args[2]).to.equal(0); + }); + + it('should do nothing when the window closes (state 1)', async () => { + const mod = loadModule(); + const { gladys, setValue } = buildGladys({ switchOn: true }); + await mod.onDeviceNewState.call( + { gladys }, + { + device_feature_external_id: 'zigbee2mqtt:window:contact', + state: 1, + }, + ); + expect(setValue.called).to.equal(false); + }); + + it('should do nothing when the changed feature is not a configured window sensor', async () => { + const mod = loadModule(); + const { gladys, setValue } = buildGladys({ switchOn: true }); + await mod.onDeviceNewState.call( + { gladys }, + { + device_feature: 'some-other-sensor', + last_value: 0, + }, + ); + expect(setValue.called).to.equal(false); + }); + + it('should not send a command when the switch is already OFF', async () => { + const mod = loadModule(); + const { gladys, setValue } = buildGladys({ switchOn: false }); + await mod.onDeviceNewState.call( + { gladys }, + { + device_feature_external_id: 'zigbee2mqtt:window:contact', + state: 0, + }, + ); + expect(setValue.called).to.equal(false); + }); +}); + +describe('thermostat.onDeviceNewState - ignored events', () => { + beforeEach(() => { + sinon.reset(); + }); + + it('should ignore a missing event', async () => { + const mod = loadModule(); + const { gladys, setValue } = buildGladys(); + + await mod.onDeviceNewState.call({ gladys }, null); + + expect(setValue.called).to.equal(false); + expect(gladys.device.get.called).to.equal(false); + }); + + it('should ignore a non-zero value without loading any device', async () => { + const mod = loadModule(); + const { gladys } = buildGladys(); + + await mod.onDeviceNewState.call({ gladys }, { device_feature: 'window-sensor', last_value: 1 }); + + expect(gladys.device.get.called).to.equal(false); + }); + + it('should ignore an event whose feature cannot be resolved', async () => { + const mod = loadModule(); + const { gladys } = buildGladys(); + + await mod.onDeviceNewState.call({ gladys }, { device_feature_external_id: 'unknown:device', state: 0 }); + + expect(gladys.device.get.called).to.equal(false); + }); + + it('should ignore an event carrying no selector at all', async () => { + const mod = loadModule(); + const { gladys } = buildGladys(); + + await mod.onDeviceNewState.call({ gladys }, { state: 0 }); + + expect(gladys.device.get.called).to.equal(false); + }); + + it('should accept the device_feature_selector shape', async () => { + const mod = loadModule(); + const { gladys, setValue } = buildGladys({ switchOn: true }); + + await mod.onDeviceNewState.call({ gladys }, { device_feature_selector: 'window-sensor', state: 0 }); + + expect(setValue.calledOnce).to.equal(true); + }); + + it('should do nothing when there is no thermostat device', async () => { + const mod = loadModule(); + const setValue = fake.resolves(null); + const gladys = { + device: { get: fake.resolves([]), setValue }, + stateManager: { get: fake.returns({ selector: 'window-sensor' }) }, + }; + + await mod.onDeviceNewState.call({ gladys }, { device_feature: 'window-sensor', last_value: 0 }); + + expect(setValue.called).to.equal(false); + }); + + it('should tolerate device.get returning nothing', async () => { + const mod = loadModule(); + const setValue = fake.resolves(null); + const gladys = { + device: { get: fake.resolves(null), setValue }, + stateManager: { get: fake.returns({ selector: 'window-sensor' }) }, + }; + + await mod.onDeviceNewState.call({ gladys }, { device_feature: 'window-sensor', last_value: 0 }); + + expect(setValue.called).to.equal(false); + }); + + it('should skip a thermostat device without a setpoint feature', async () => { + const mod = loadModule(); + const setValue = fake.resolves(null); + const gladys = { + device: { + get: fake.resolves([{ features: [{ selector: 'x', category: 'light', type: 'binary' }], params: [] }]), + setValue, + }, + stateManager: { get: fake.returns({ selector: 'window-sensor' }) }, + }; + + await mod.onDeviceNewState.call({ gladys }, { device_feature: 'window-sensor', last_value: 0 }); + + expect(setValue.called).to.equal(false); + }); + + it('should skip a thermostat whose window sensor is a different feature', async () => { + const mod = loadModule(); + const { gladys, setValue } = buildGladys({ switchOn: true }); + + await mod.onDeviceNewState.call({ gladys }, { device_feature: 'another-window', last_value: 0 }); + + expect(setValue.called).to.equal(false); + }); + + it('should skip a thermostat without params', async () => { + const mod = loadModule(); + const setValue = fake.resolves(null); + const gladys = { + device: { + get: fake.resolves([ + { + features: [ + { + selector: 'thermostat-living-room', + category: DEVICE_FEATURE_CATEGORIES.THERMOSTAT, + type: DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE, + }, + ], + params: [], + }, + ]), + setValue, + }, + stateManager: { get: fake.returns({ selector: 'window-sensor' }) }, + }; + + await mod.onDeviceNewState.call({ gladys }, { device_feature: 'window-sensor', last_value: 0 }); + + expect(setValue.called).to.equal(false); + }); + + it('should not re-cut a switch that is already off', async () => { + const mod = loadModule(); + const { gladys, setValue } = buildGladys({ switchOn: false }); + + await mod.onDeviceNewState.call({ gladys }, { device_feature: 'window-sensor', last_value: 0 }); + + expect(setValue.called).to.equal(false); + }); + + it('should swallow a failure while turning the switch off', async () => { + const mod = loadModule(); + const { gladys } = buildGladys({ switchOn: true }); + gladys.device.setValue = fake.rejects(new Error('switch offline')); + + await mod.onDeviceNewState.call({ gladys }, { device_feature: 'window-sensor', last_value: 0 }); + + expect(gladys.device.setValue.calledOnce).to.equal(true); + }); + + it('should swallow a failure while loading the thermostats', async () => { + const mod = loadModule(); + const setValue = fake.resolves(null); + const gladys = { + device: { get: fake.rejects(new Error('db down')), setValue }, + stateManager: { get: fake.returns({ selector: 'window-sensor' }) }, + }; + + await mod.onDeviceNewState.call({ gladys }, { device_feature: 'window-sensor', last_value: 0 }); + + expect(setValue.called).to.equal(false); + }); +}); + +describe('thermostat.onDeviceNewState - window selector cache', () => { + beforeEach(() => { + sinon.reset(); + }); + + it('should reject a non-window selector without loading the thermostat devices twice', async () => { + const mod = loadModule(); + const { gladys, setValue } = buildGladys(); + const handler = { gladys, windowSelectorsCache: null }; + + // First event builds the cache, the next ones must hit it instead of querying again. + await mod.onDeviceNewState.call(handler, { device_feature: 'some-other-sensor', last_value: 0 }); + await mod.onDeviceNewState.call(handler, { device_feature: 'yet-another-sensor', last_value: 0 }); + await mod.onDeviceNewState.call(handler, { device_feature: 'a-third-sensor', last_value: 0 }); + + expect(gladys.device.get.callCount).to.equal(1); + expect(setValue.called).to.equal(false); + }); + + it('should still cut the switch for a cached window selector', async () => { + const mod = loadModule(); + const { gladys, setValue } = buildGladys({ switchOn: true }); + const handler = { gladys, windowSelectorsCache: null }; + + await mod.onDeviceNewState.call(handler, { device_feature: 'some-other-sensor', last_value: 0 }); + await mod.onDeviceNewState.call(handler, { device_feature: 'window-sensor', last_value: 0 }); + + expect(setValue.calledOnce).to.equal(true); + }); + + it('should rebuild the cache once it has been invalidated', async () => { + const mod = loadModule(); + const { gladys } = buildGladys(); + const handler = { gladys, windowSelectorsCache: null, invalidateWindowCache: mod.invalidateWindowCache }; + + await mod.onDeviceNewState.call(handler, { device_feature: 'some-other-sensor', last_value: 0 }); + expect(gladys.device.get.callCount).to.equal(1); + + handler.invalidateWindowCache(); + expect(handler.windowSelectorsCache).to.equal(null); + + await mod.onDeviceNewState.call(handler, { device_feature: 'some-other-sensor', last_value: 0 }); + expect(gladys.device.get.callCount).to.equal(2); + }); + + it('should expose the configured window selectors', async () => { + const mod = loadModule(); + const { gladys } = buildGladys(); + + const selectors = await mod.getWindowSelectors.call({ gladys, windowSelectorsCache: null }); + + expect([...selectors]).to.deep.equal(['window-sensor']); + }); + + it('should return an empty set when no thermostat configures a window', async () => { + const mod = loadModule(); + const gladys = { device: { get: fake.resolves([{ params: [] }, {}]) } }; + + const selectors = await mod.getWindowSelectors.call({ gladys, windowSelectorsCache: null }); + + expect(selectors.size).to.equal(0); + }); +}); + +describe('thermostat.onDeviceNewState - per-device rejection after the cache hit', () => { + beforeEach(() => { + sinon.reset(); + }); + + // The cache says "this selector is a window somewhere", but each thermostat is + // still checked individually: a house can have several, only one of which is + // wired to the sensor that changed. + const buildTwoThermostats = (second) => { + const setValue = fake.resolves(null); + const configured = { + features: [ + { + selector: 'thermostat-living-room', + category: DEVICE_FEATURE_CATEGORIES.THERMOSTAT, + type: DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE, + }, + ], + params: [ + { name: 'THERMOSTAT_WINDOW_FEATURE', value: 'window-sensor' }, + { name: 'THERMOSTAT_SWITCH_FEATURE', value: 'heater-switch' }, + ], + }; + return { + setValue, + gladys: { + device: { + get: fake((query) => { + if (query && query.service === 'thermostat') { + return Promise.resolve([second, configured]); + } + if (query && query.device_feature_selectors === 'heater-switch') { + return Promise.resolve([{ features: [{ selector: 'heater-switch', last_value: 1 }] }]); + } + return Promise.resolve([]); + }), + setValue, + }, + stateManager: { get: fake.returns(null) }, + }, + }; + }; + + it('should skip a thermostat without a setpoint feature', async () => { + const mod = loadModule(); + const { gladys, setValue } = buildTwoThermostats({ features: [], params: [] }); + + await mod.onDeviceNewState.call({ gladys }, { device_feature: 'window-sensor', last_value: 0 }); + + // The other thermostat is still regulated + expect(setValue.calledOnce).to.equal(true); + }); + + it('should skip a thermostat watching another window', async () => { + const mod = loadModule(); + const { gladys, setValue } = buildTwoThermostats({ + features: [ + { + selector: 'thermostat-bedroom', + category: DEVICE_FEATURE_CATEGORIES.THERMOSTAT, + type: DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE, + }, + ], + params: [ + { name: 'THERMOSTAT_WINDOW_FEATURE', value: 'another-window' }, + { name: 'THERMOSTAT_SWITCH_FEATURE', value: 'bedroom-switch' }, + ], + }); + + await mod.onDeviceNewState.call({ gladys }, { device_feature: 'window-sensor', last_value: 0 }); + + expect(setValue.calledOnce).to.equal(true); + expect(setValue.firstCall.args[1].selector).to.equal('heater-switch'); + }); + + it('should skip a thermostat whose window has no switch wired', async () => { + const mod = loadModule(); + const { gladys, setValue } = buildTwoThermostats({ + features: [ + { + selector: 'thermostat-bedroom', + category: DEVICE_FEATURE_CATEGORIES.THERMOSTAT, + type: DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE, + }, + ], + params: [{ name: 'THERMOSTAT_WINDOW_FEATURE', value: 'window-sensor' }], + }); + + await mod.onDeviceNewState.call({ gladys }, { device_feature: 'window-sensor', last_value: 0 }); + + expect(setValue.calledOnce).to.equal(true); + }); + + it('should skip a thermostat that lost its params between the two reads', async () => { + const mod = loadModule(); + const setValue = fake.resolves(null); + let call = 0; + const gladys = { + device: { + get: fake((query) => { + if (query && query.service === 'thermostat') { + call += 1; + // The cache is built from a configured device; by the time the second + // read happens its params are gone, so buildParamsConfig returns null. + return Promise.resolve( + call === 1 + ? [{ params: [{ name: 'THERMOSTAT_WINDOW_FEATURE', value: 'window-sensor' }] }] + : [ + { + features: [ + { + selector: 'thermostat-living-room', + category: DEVICE_FEATURE_CATEGORIES.THERMOSTAT, + type: DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE, + }, + ], + params: [], + }, + ], + ); + } + return Promise.resolve([]); + }), + setValue, + }, + stateManager: { get: fake.returns(null) }, + }; + + await mod.onDeviceNewState.call({ gladys }, { device_feature: 'window-sensor', last_value: 0 }); + + expect(setValue.called).to.equal(false); + }); + + it('should stop when the thermostat devices disappear between the two reads', async () => { + const mod = loadModule(); + const setValue = fake.resolves(null); + let call = 0; + const gladys = { + device: { + get: fake(() => { + call += 1; + // First read builds the cache, the device is deleted right after. + return Promise.resolve( + call === 1 ? [{ params: [{ name: 'THERMOSTAT_WINDOW_FEATURE', value: 'window-sensor' }] }] : [], + ); + }), + setValue, + }, + stateManager: { get: fake.returns(null) }, + }; + + await mod.onDeviceNewState.call({ gladys }, { device_feature: 'window-sensor', last_value: 0 }); + + expect(setValue.called).to.equal(false); + }); +}); diff --git a/server/test/services/thermostat/lib/thermostat.regulateDevice.test.js b/server/test/services/thermostat/lib/thermostat.regulateDevice.test.js new file mode 100644 index 0000000000..8721eda423 --- /dev/null +++ b/server/test/services/thermostat/lib/thermostat.regulateDevice.test.js @@ -0,0 +1,616 @@ +const { expect } = require('chai'); +const sinon = require('sinon').createSandbox(); +const proxyquire = require('proxyquire').noCallThru(); + +const { fake, assert } = sinon; + +const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES, EVENTS } = require('../../../../utils/constants'); +const { getCurrentDayAndMinutes } = require('../../../../utils/thermostatSchedule'); + +const setpointFeature = (extra = {}) => ({ + selector: 'thermostat-living-room', + category: DEVICE_FEATURE_CATEGORIES.THERMOSTAT, + type: DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE, + ...extra, +}); + +const todayDow = getCurrentDayAndMinutes(new Date(), 'Europe/Paris').dayOfWeek; + +const fullDaySchedule = (preset) => ({ + selector: 'my-schedule', + slots: [{ day_of_week: todayDow, start_time: '00:00', end_time: '00:00', preset }], +}); + +const load = (schedule) => + proxyquire('../../../../services/thermostat/lib/thermostat.applySchedules', { + '../../../models': { + ThermostatSchedule: { findOne: fake.resolves(schedule) }, + ThermostatScheduleSlot: {}, + }, + '../../../utils/logger': { + debug: fake.returns(null), + info: fake.returns(null), + warn: fake.returns(null), + }, + }); + +const params = (map) => Object.keys(map).map((name) => ({ name, value: map[name] })); + +const baseParams = (overrides = {}) => + params({ + THERMOSTAT_TEMPERATURE_FEATURE: 'temp-sensor', + THERMOSTAT_SWITCH_FEATURE: 'heater-switch', + THERMOSTAT_ACTIVE_SCHEDULE: 'my-schedule', + THERMOSTAT_MODE: 'heating', + THERMOSTAT_PRESET_COMFORT: '21', + ...overrides, + }); + +// Feature lookups go through gladys.device.get({ device_feature_selectors }). +const buildGladys = ({ features = {}, variables = {}, getOverride = null } = {}) => { + const deviceGet = fake((query) => { + if (getOverride) { + const overridden = getOverride(query); + if (overridden !== undefined) { + return overridden; + } + } + const selector = query && query.device_feature_selectors; + if (selector && features[selector] !== undefined) { + return Promise.resolve([{ selector: `${selector}-device`, features: [features[selector]] }]); + } + return Promise.resolve([]); + }); + return { + device: { get: deviceGet, setValue: fake.resolves(null), saveState: fake.resolves(null) }, + variable: { + getValue: fake((key) => Promise.resolve(variables[key] !== undefined ? variables[key] : null)), + setValue: fake.resolves(null), + }, + event: { emit: fake.returns(null) }, + }; +}; + +const standardFeatures = ({ temp = 18, switchOn = false, windowOpen = null } = {}) => { + const features = { + 'temp-sensor': { selector: 'temp-sensor', last_value: temp }, + 'heater-switch': { selector: 'heater-switch', last_value: switchOn ? 1 : 0 }, + }; + if (windowOpen !== null) { + features['window-sensor'] = { selector: 'window-sensor', last_value: windowOpen ? 0 : 1 }; + } + return features; +}; + +const regulate = async (mod, gladys, device) => mod.regulateDevice(gladys, device, todayDow, 12 * 60); + +describe('thermostat.regulateDevice', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should skip a device without a target-temperature feature', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys(); + + await regulate(mod, gladys, { features: [{ selector: 'x', category: 'light', type: 'binary' }] }); + + assert.notCalled(gladys.device.setValue); + }); + + it('should skip a device with no features at all', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys(); + + await regulate(mod, gladys, {}); + + assert.notCalled(gladys.device.setValue); + }); + + it('should stop when no config can be resolved', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys(); + + await regulate(mod, gladys, { features: [setpointFeature()], params: [] }); + + assert.notCalled(gladys.device.setValue); + }); + + it('should keep going when the window sensor cannot be read', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ + features: standardFeatures({ temp: 18 }), + getOverride: (query) => { + if (query && query.device_feature_selectors === 'window-sensor') { + return Promise.reject(new Error('sensor offline')); + } + return undefined; + }, + }); + + await regulate(mod, gladys, { + features: [setpointFeature()], + params: baseParams({ THERMOSTAT_WINDOW_FEATURE: 'window-sensor' }), + }); + + // Regulation continued despite the sensor error + assert.calledOnce(gladys.device.setValue); + }); + + it('should cut the switch and stop when the window is open', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ features: standardFeatures({ temp: 18, switchOn: true, windowOpen: true }) }); + + await regulate(mod, gladys, { + features: [setpointFeature()], + params: baseParams({ THERMOSTAT_WINDOW_FEATURE: 'window-sensor' }), + }); + + const [, , value] = gladys.device.setValue.firstCall.args; + expect(value).to.equal(0); + }); + + it('should not try to actuate on an open window without a switch', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ features: standardFeatures({ windowOpen: true }) }); + + await regulate(mod, gladys, { + features: [setpointFeature()], + params: params({ + THERMOSTAT_TEMPERATURE_FEATURE: 'temp-sensor', + THERMOSTAT_WINDOW_FEATURE: 'window-sensor', + THERMOSTAT_ACTIVE_SCHEDULE: 'my-schedule', + }), + }); + + assert.notCalled(gladys.device.setValue); + }); + + describe('manual mode', () => { + const manualVariables = (extra = {}) => ({ + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_MODE: 'true', + ...extra, + }); + + it('should regulate on the manual setpoint while the timer runs', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ + features: standardFeatures({ temp: 15 }), + variables: manualVariables({ + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL: String(Date.now() + 60000), + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_SETPOINT: JSON.stringify({ setpoint: 22 }), + }), + }); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + const [, , value] = gladys.device.setValue.firstCall.args; + expect(value).to.equal(1); + }); + + it('should hold manual mode forever when no expiry is stored', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ + features: standardFeatures({ temp: 15 }), + variables: manualVariables({ + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_SETPOINT: JSON.stringify({ setpoint: 22 }), + }), + }); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + assert.neverCalledWith(gladys.variable.setValue, 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_MODE', 'false'); + }); + + it('should ignore a malformed manual setpoint', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ + features: standardFeatures({ temp: 15 }), + variables: manualVariables({ + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_SETPOINT: 'not-json', + }), + }); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + assert.notCalled(gladys.device.setValue); + }); + + it('should do nothing when the manual setpoint variable is absent', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ + features: standardFeatures({ temp: 15 }), + variables: manualVariables(), + }); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + assert.notCalled(gladys.device.setValue); + }); + + it('should not actuate in manual mode without a temperature reading', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ + features: { + 'temp-sensor': { selector: 'temp-sensor', last_value: null }, + 'heater-switch': { selector: 'heater-switch', last_value: 0 }, + }, + variables: manualVariables({ + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_SETPOINT: JSON.stringify({ setpoint: 22 }), + }), + }); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + assert.notCalled(gladys.device.setValue); + }); + + it('should revert to the schedule once the manual timer expired', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ + features: standardFeatures({ temp: 18 }), + variables: manualVariables({ + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL: String(Date.now() - 60000), + }), + }); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + assert.calledWith(gladys.variable.setValue, 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_MODE', 'false'); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL); + }); + }); + + describe('preset resolution', () => { + it('should fall back to the preset variable when the schedule has no slot for now', async () => { + const mod = load({ selector: 'my-schedule', slots: [] }); + const gladys = buildGladys({ + features: standardFeatures({ temp: 18 }), + variables: { THERMOSTAT_THERMOSTAT_LIVING_ROOM_PRESET: 'comfort' }, + }); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + assert.calledOnce(gladys.device.setValue); + }); + + it('should fall back to the preset variable when the schedule is missing', async () => { + const mod = load(null); + const gladys = buildGladys({ + features: standardFeatures({ temp: 18 }), + variables: { THERMOSTAT_THERMOSTAT_LIVING_ROOM_PRESET: 'comfort' }, + }); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + assert.calledOnce(gladys.device.setValue); + }); + + it('should ignore a legacy active-schedule variable: the schedule is a device param', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ + features: standardFeatures({ temp: 18 }), + variables: { THERMOSTAT_ACTIVE_SCHEDULE_THERMOSTAT_LIVING_ROOM: 'my-schedule' }, + }); + + await regulate(mod, gladys, { + features: [setpointFeature()], + params: baseParams({ THERMOSTAT_ACTIVE_SCHEDULE: '' }), + }); + + // No schedule and no preset variable: nothing to regulate on. + assert.notCalled(gladys.device.setValue); + }); + + it('should skip the setpoint write when it already matches', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ features: standardFeatures({ temp: 18 }) }); + + await regulate(mod, gladys, { + features: [setpointFeature({ last_value: 21 })], + params: baseParams(), + }); + + assert.notCalled(gladys.device.saveState); + }); + + it('should survive a failing setpoint write', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ features: standardFeatures({ temp: 18 }) }); + gladys.device.saveState = fake.rejects(new Error('db down')); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + // The switch is still actuated even though the setpoint could not be stored + assert.calledOnce(gladys.device.setValue); + }); + + it('should not broadcast the preset when it did not change', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ + features: standardFeatures({ temp: 18 }), + variables: { THERMOSTAT_THERMOSTAT_LIVING_ROOM_PRESET: 'comfort' }, + }); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + assert.notCalled(gladys.variable.setValue); + }); + }); + + describe('switch actuation', () => { + it('should stop when no switch is configured', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ features: standardFeatures({ temp: 18 }) }); + + await regulate(mod, gladys, { + features: [setpointFeature()], + params: params({ + THERMOSTAT_TEMPERATURE_FEATURE: 'temp-sensor', + THERMOSTAT_ACTIVE_SCHEDULE: 'my-schedule', + }), + }); + + assert.notCalled(gladys.device.setValue); + }); + + it('should stop when no temperature feature is configured', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ features: standardFeatures({ temp: 18 }) }); + + await regulate(mod, gladys, { + features: [setpointFeature()], + params: params({ + THERMOSTAT_SWITCH_FEATURE: 'heater-switch', + THERMOSTAT_ACTIVE_SCHEDULE: 'my-schedule', + THERMOSTAT_PRESET_COMFORT: '21', + }), + }); + + assert.notCalled(gladys.device.setValue); + }); + + it('should stop when the temperature cannot be read', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ + features: standardFeatures({ temp: 18 }), + getOverride: (query) => { + if (query && query.device_feature_selectors === 'temp-sensor') { + return Promise.reject(new Error('sensor offline')); + } + return undefined; + }, + }); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + assert.notCalled(gladys.device.setValue); + }); + + it('should stop when the temperature sensor has no reading yet', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ + features: { + 'temp-sensor': { selector: 'temp-sensor', last_value: null }, + 'heater-switch': { selector: 'heater-switch', last_value: 0 }, + }, + }); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + assert.notCalled(gladys.device.setValue); + }); + + it('should stop when the switch feature does not exist', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ + features: { 'temp-sensor': { selector: 'temp-sensor', last_value: 18 } }, + }); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + assert.notCalled(gladys.device.setValue); + }); + + it('should leave the switch alone when it is already in the wanted state', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ features: standardFeatures({ temp: 18, switchOn: true }) }); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + assert.notCalled(gladys.device.setValue); + }); + + it('should survive a failing switch write', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ features: standardFeatures({ temp: 18 }) }); + gladys.device.setValue = fake.rejects(new Error('switch offline')); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + assert.calledOnce(gladys.device.setValue); + }); + + it('should turn the switch off on the off preset', async () => { + const mod = load(fullDaySchedule('off')); + const gladys = buildGladys({ features: standardFeatures({ temp: 18, switchOn: true }) }); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + const [, , value] = gladys.device.setValue.firstCall.args; + expect(value).to.equal(0); + }); + }); +}); + +describe('thermostat.regulateDevice - resilience', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should skip actuation when the window switch feature is unknown', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ + features: { + 'temp-sensor': { selector: 'temp-sensor', last_value: 18 }, + 'window-sensor': { selector: 'window-sensor', last_value: 0 }, + // heater-switch deliberately missing: getFeatureBySelector returns null + }, + }); + + await regulate(mod, gladys, { + features: [setpointFeature()], + params: baseParams({ THERMOSTAT_WINDOW_FEATURE: 'window-sensor' }), + }); + + assert.notCalled(gladys.device.setValue); + }); + + it('should treat unreadable preset variables as absent', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ features: standardFeatures({ temp: 18 }) }); + gladys.variable.getValue = fake.rejects(new Error('variable table locked')); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + // The schedule still resolves the preset, so regulation goes on + assert.calledOnce(gladys.device.setValue); + }); + + it('should treat an unreadable manual expiry as no expiry', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ features: standardFeatures({ temp: 15 }) }); + gladys.variable.getValue = fake((key) => { + if (key === 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_MODE') { + return Promise.resolve('true'); + } + if (key === 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL') { + return Promise.reject(new Error('unreadable')); + } + if (key === 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_SETPOINT') { + return Promise.resolve(JSON.stringify({ setpoint: 22 })); + } + return Promise.resolve(null); + }); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + // Manual mode holds rather than silently reverting to the schedule + assert.neverCalledWith(gladys.variable.setValue, 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_MODE', 'false'); + assert.calledOnce(gladys.device.setValue); + }); + + it('should treat an unreadable manual setpoint as absent', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ features: standardFeatures({ temp: 15 }) }); + gladys.variable.getValue = fake((key) => { + if (key === 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_MODE') { + return Promise.resolve('true'); + } + if (key === 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_SETPOINT') { + return Promise.reject(new Error('unreadable')); + } + return Promise.resolve(null); + }); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + assert.notCalled(gladys.device.setValue); + }); + + it('should treat an empty active-schedule param as no schedule', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ features: standardFeatures({ temp: 18 }) }); + gladys.variable.getValue = fake.resolves(null); + + await regulate(mod, gladys, { + features: [setpointFeature()], + params: baseParams({ THERMOSTAT_ACTIVE_SCHEDULE: '' }), + }); + + // No schedule and no preset: nothing to regulate on + assert.notCalled(gladys.device.setValue); + }); +}); + +describe('thermostat.regulateDevice - defensive paths', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should default to heating when no mode is configured', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ features: standardFeatures({ temp: 18 }) }); + + await regulate(mod, gladys, { + features: [setpointFeature()], + params: params({ + THERMOSTAT_TEMPERATURE_FEATURE: 'temp-sensor', + THERMOSTAT_SWITCH_FEATURE: 'heater-switch', + THERMOSTAT_ACTIVE_SCHEDULE: 'my-schedule', + THERMOSTAT_PRESET_COMFORT: '21', + }), + }); + + // Heating below the setpoint turns the switch ON + const [, , value] = gladys.device.setValue.firstCall.args; + expect(value).to.equal(1); + }); + + it('should keep regulating when the configured window feature does not exist', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ features: standardFeatures({ temp: 18 }) }); + + await regulate(mod, gladys, { + features: [setpointFeature()], + params: baseParams({ THERMOSTAT_WINDOW_FEATURE: 'missing-window' }), + }); + + assert.calledOnce(gladys.device.setValue); + }); + + it('should stop when the temperature feature does not exist', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ + features: { 'heater-switch': { selector: 'heater-switch', last_value: 0 } }, + }); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + assert.notCalled(gladys.device.setValue); + }); +}); + +describe('thermostat.regulateDevice - config defaults', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should default the mode to heating when the params omit it', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ features: standardFeatures({ temp: 18 }) }); + + await regulate(mod, gladys, { + features: [setpointFeature()], + params: [ + { name: 'THERMOSTAT_TEMPERATURE_FEATURE', value: 'temp-sensor' }, + { name: 'THERMOSTAT_SWITCH_FEATURE', value: 'heater-switch' }, + { name: 'THERMOSTAT_PRESET_COMFORT', value: '21' }, + { name: 'THERMOSTAT_ACTIVE_SCHEDULE', value: 'my-schedule' }, + ], + }); + + // Heating below the setpoint turns the switch ON + const [, , value] = gladys.device.setValue.firstCall.args; + expect(value).to.equal(1); + }); + + it('should skip a device carrying no params at all', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ features: standardFeatures({ temp: 18 }) }); + + await regulate(mod, gladys, { features: [setpointFeature()], params: [] }); + + assert.notCalled(gladys.device.setValue); + }); +}); diff --git a/server/test/services/thermostat/lib/thermostat.schedules.test.js b/server/test/services/thermostat/lib/thermostat.schedules.test.js new file mode 100644 index 0000000000..f1a7ec6da3 --- /dev/null +++ b/server/test/services/thermostat/lib/thermostat.schedules.test.js @@ -0,0 +1,251 @@ +const { expect } = require('chai'); +const sinon = require('sinon').createSandbox(); +const proxyquire = require('proxyquire').noCallThru(); + +const { fake, assert } = sinon; + +const noopLogger = { + debug: fake.returns(null), + info: fake.returns(null), + warn: fake.returns(null), +}; + +// Minimal Sequelize stand-in: only the calls these modules actually make. +const buildDb = ({ schedule = null, duplicate = null, created = null } = {}) => { + const scheduleInstance = schedule && { + id: schedule.id || 'schedule-id', + ...schedule, + update: fake.resolves(null), + destroy: fake.resolves(null), + get: () => ({ ...schedule }), + }; + + return { + ThermostatSchedule: { + findOne: fake(async ({ where }) => { + if (where.selector) { + return scheduleInstance; + } + return duplicate; + }), + findAll: fake.resolves([{ get: () => ({ name: 'Work week', slots: [] }) }]), + findByPk: fake.resolves({ get: () => created || { name: 'Work week', slots: [] } }), + create: fake.resolves({ id: 'created-id' }), + }, + ThermostatScheduleSlot: { + destroy: fake.resolves(null), + bulkCreate: fake.resolves(null), + }, + sequelize: { + transaction: async (cb) => cb('tx'), + }, + scheduleInstance, + }; +}; + +const load = (name, db) => + proxyquire(`../../../../services/thermostat/lib/thermostat.${name}`, { + '../../../models': db, + '../../../utils/logger': noopLogger, + '../../../utils/slugify': { slugify: (v) => v.toLowerCase().replace(/[^a-z0-9]+/g, '-') }, + }); + +describe('thermostat.createSchedule', () => { + it('should create a schedule with its slots', async () => { + const db = buildDb(); + const { createSchedule } = load('createSchedule', db); + + await createSchedule({ + name: 'Work week', + slots: [{ day_of_week: 0, start_time: '08:00', end_time: '18:00', preset: 'comfort' }], + }); + + assert.calledOnce(db.ThermostatSchedule.create); + const [payload] = db.ThermostatSchedule.create.firstCall.args; + expect(payload.name).to.equal('Work week'); + expect(payload.slots).to.have.lengthOf(1); + expect(payload.slots[0]).to.deep.equal({ + day_of_week: 0, + start_time: '08:00', + end_time: '18:00', + preset: 'comfort', + }); + }); + + it('should accept a schedule without any slot', async () => { + const db = buildDb(); + const { createSchedule } = load('createSchedule', db); + + await createSchedule({ name: 'Empty' }); + + const [payload] = db.ThermostatSchedule.create.firstCall.args; + expect(payload.slots).to.deep.equal([]); + }); + + it('should reject a duplicate name', async () => { + const db = buildDb({ duplicate: { id: 'other-id', name: 'Work week' } }); + const { createSchedule } = load('createSchedule', db); + + let error = null; + try { + await createSchedule({ name: 'Work week', slots: [] }); + } catch (e) { + error = e; + } + + expect(error).to.not.equal(null); + expect(error.message).to.contain('already exists'); + assert.notCalled(db.ThermostatSchedule.create); + }); + + it('should persist the day coerced by Joi, not the raw string', async () => { + const db = buildDb(); + const { createSchedule } = load('createSchedule', db); + + // A day arriving as a string would otherwise be stored as such and then + // match no regulation tick, since the loop compares against a number. + await createSchedule({ + name: 'Coerced', + slots: [{ day_of_week: '3', start_time: '08:00', end_time: '18:00', preset: 'comfort' }], + }); + + const [payload] = db.ThermostatSchedule.create.firstCall.args; + expect(payload.slots[0].day_of_week).to.equal(3); + }); +}); + +describe('thermostat.updateSchedule', () => { + it('should replace name and slots in a single transaction', async () => { + const db = buildDb({ schedule: { id: 'schedule-id', selector: 'my-schedule' } }); + const { updateSchedule } = load('updateSchedule', db); + + await updateSchedule('my-schedule', { + name: 'Renamed', + slots: [{ day_of_week: 2, start_time: '06:00', end_time: '09:00', preset: 'eco' }], + }); + + assert.calledOnce(db.scheduleInstance.update); + assert.calledOnce(db.ThermostatScheduleSlot.destroy); + assert.calledOnce(db.ThermostatScheduleSlot.bulkCreate); + + const [rows] = db.ThermostatScheduleSlot.bulkCreate.firstCall.args; + expect(rows[0]).to.deep.equal({ + schedule_id: 'schedule-id', + day_of_week: 2, + start_time: '06:00', + end_time: '09:00', + preset: 'eco', + }); + }); + + it('should not re-create slots when the new schedule is empty', async () => { + const db = buildDb({ schedule: { id: 'schedule-id', selector: 'my-schedule' } }); + const { updateSchedule } = load('updateSchedule', db); + + await updateSchedule('my-schedule', { name: 'Renamed', slots: [] }); + + assert.calledOnce(db.ThermostatScheduleSlot.destroy); + assert.notCalled(db.ThermostatScheduleSlot.bulkCreate); + }); + + it('should throw when the schedule does not exist', async () => { + const db = buildDb(); + const { updateSchedule } = load('updateSchedule', db); + + let error = null; + try { + await updateSchedule('unknown', { name: 'x', slots: [] }); + } catch (e) { + error = e; + } + + expect(error).to.not.equal(null); + expect(error.message).to.contain('Schedule not found'); + }); + + it('should reject renaming onto another existing schedule', async () => { + const db = buildDb({ + schedule: { id: 'schedule-id', selector: 'my-schedule' }, + duplicate: { id: 'another-id', name: 'Taken' }, + }); + const { updateSchedule } = load('updateSchedule', db); + + let error = null; + try { + await updateSchedule('my-schedule', { name: 'Taken', slots: [] }); + } catch (e) { + error = e; + } + + expect(error).to.not.equal(null); + expect(error.message).to.contain('already exists'); + assert.notCalled(db.ThermostatScheduleSlot.destroy); + }); + + it('should persist the day coerced by Joi on update too', async () => { + const db = buildDb({ schedule: { id: 'schedule-id', name: 'Work week', update: fake.resolves(null) } }); + const { updateSchedule } = load('updateSchedule', db); + + await updateSchedule('my-schedule', { + name: 'Work week', + slots: [{ day_of_week: '5', start_time: '08:00', end_time: '18:00', preset: 'eco' }], + }); + + const [rows] = db.ThermostatScheduleSlot.bulkCreate.firstCall.args; + expect(rows[0].day_of_week).to.equal(5); + }); + + it('should allow keeping its own name', async () => { + const db = buildDb({ + schedule: { id: 'schedule-id', selector: 'my-schedule' }, + duplicate: { id: 'schedule-id', name: 'Same name' }, + }); + const { updateSchedule } = load('updateSchedule', db); + + await updateSchedule('my-schedule', { name: 'Same name', slots: [] }); + + assert.calledOnce(db.scheduleInstance.update); + }); +}); + +describe('thermostat.deleteSchedule', () => { + it('should delete the schedule and its slots', async () => { + const db = buildDb({ schedule: { id: 'schedule-id', selector: 'my-schedule' } }); + const { deleteSchedule } = load('deleteSchedule', db); + + await deleteSchedule('my-schedule'); + + assert.calledOnce(db.ThermostatScheduleSlot.destroy); + expect(db.ThermostatScheduleSlot.destroy.firstCall.args[0]).to.deep.equal({ + where: { schedule_id: 'schedule-id' }, + }); + assert.calledOnce(db.scheduleInstance.destroy); + }); + + it('should throw when the schedule does not exist', async () => { + const db = buildDb(); + const { deleteSchedule } = load('deleteSchedule', db); + + let error = null; + try { + await deleteSchedule('unknown'); + } catch (e) { + error = e; + } + + expect(error).to.not.equal(null); + expect(error.message).to.contain('Schedule not found'); + assert.notCalled(db.ThermostatScheduleSlot.destroy); + }); +}); + +describe('thermostat.getSchedules', () => { + it('should return plain schedules', async () => { + const db = buildDb(); + const { getSchedules } = load('getSchedules', db); + + const schedules = await getSchedules(); + + expect(schedules).to.deep.equal([{ name: 'Work week', slots: [] }]); + }); +}); diff --git a/server/test/services/thermostat/lib/thermostat.setValue.test.js b/server/test/services/thermostat/lib/thermostat.setValue.test.js new file mode 100644 index 0000000000..bb95cbe646 --- /dev/null +++ b/server/test/services/thermostat/lib/thermostat.setValue.test.js @@ -0,0 +1,135 @@ +const { expect } = require('chai'); +const sinon = require('sinon').createSandbox(); +const proxyquire = require('proxyquire').noCallThru(); + +const { fake, assert } = sinon; + +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../utils/constants'); +const { MANUAL_DURATION_MS } = require('../../../../utils/thermostatConstants'); + +const load = () => + proxyquire('../../../../services/thermostat/lib/thermostat.setValue', { + '../../../utils/logger': { + debug: fake.returns(null), + info: fake.returns(null), + warn: fake.returns(null), + }, + }); + +const buildHandler = () => { + const { setValue } = load(); + return { + gladys: { + device: { saveState: fake.resolves(null) }, + variable: { setValue: fake.resolves(null) }, + event: { emit: fake.returns(null) }, + }, + serviceId: 'service-id', + triggerApplySchedules: fake.returns(null), + setValue, + }; +}; + +const deviceFeature = { selector: 'thermostat-living-room' }; + +describe('thermostat.setValue', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should persist the value through saveState', async () => { + const handler = buildHandler(); + + await handler.setValue({}, deviceFeature, 21.5); + + assert.calledWith(handler.gladys.device.saveState, deviceFeature, 21.5); + }); + + it('should hold the value as a manual override so the schedule does not overwrite it', async () => { + const clock = sinon.useFakeTimers(1_700_000_000_000); + const handler = buildHandler(); + + await handler.setValue({}, deviceFeature, 21.5); + + assert.calledWith( + handler.gladys.variable.setValue, + 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_SETPOINT', + JSON.stringify({ setpoint: 21.5 }), + ); + assert.calledWith( + handler.gladys.variable.setValue, + 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL', + String(clock.now + MANUAL_DURATION_MS), + ); + assert.calledWith(handler.gladys.variable.setValue, 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_MODE', 'true'); + }); + + it('should broadcast the manual mode change to open dashboards', async () => { + const handler = buildHandler(); + + await handler.setValue({}, deviceFeature, 19); + + assert.calledWith(handler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.THERMOSTAT.MANUAL_MODE_UPDATED, + payload: { key: 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_MODE', value: 'true' }, + }); + }); + + it('should trigger a regulation pass', async () => { + const handler = buildHandler(); + + await handler.setValue({}, deviceFeature, 19); + + assert.calledOnce(handler.triggerApplySchedules); + }); + + it('should build the variable keys from the feature selector', async () => { + const handler = buildHandler(); + + await handler.setValue({}, { selector: 'my-second-thermostat' }, 20); + + const keys = handler.gladys.variable.setValue.getCalls().map((call) => call.args[0]); + expect(keys).to.deep.equal([ + 'THERMOSTAT_MY_SECOND_THERMOSTAT_MANUAL_SETPOINT', + 'THERMOSTAT_MY_SECOND_THERMOSTAT_MANUAL_UNTIL', + 'THERMOSTAT_MY_SECOND_THERMOSTAT_MANUAL_MODE', + ]); + }); + + it('should hold the setpoint for the duration configured on the device', async () => { + const clock = sinon.useFakeTimers(1_700_000_000_000); + const handler = buildHandler(); + const device = { params: [{ name: 'THERMOSTAT_MANUAL_DURATION', value: '45' }] }; + + await handler.setValue(device, deviceFeature, 21.5); + + assert.calledWith( + handler.gladys.variable.setValue, + 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL', + String(clock.now + 45 * 60 * 1000), + ); + }); + + it('should fall back to the shared default when the device configures no duration', async () => { + const clock = sinon.useFakeTimers(1_700_000_000_000); + const handler = buildHandler(); + + await handler.setValue({ params: [] }, deviceFeature, 21.5); + + assert.calledWith( + handler.gladys.variable.setValue, + 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL', + String(clock.now + MANUAL_DURATION_MS), + ); + }); + + it('should write the runtime variables in this service scope', async () => { + const handler = buildHandler(); + + await handler.setValue({}, deviceFeature, 21.5); + + handler.gladys.variable.setValue.getCalls().forEach((call) => { + expect(call.args[2]).to.equal('service-id'); + }); + }); +}); diff --git a/server/test/services/thermostat/lib/thermostat.setVariable.test.js b/server/test/services/thermostat/lib/thermostat.setVariable.test.js new file mode 100644 index 0000000000..5d1ea7171f --- /dev/null +++ b/server/test/services/thermostat/lib/thermostat.setVariable.test.js @@ -0,0 +1,177 @@ +const { expect } = require('chai'); +const sinon = require('sinon').createSandbox(); +const proxyquire = require('proxyquire').noCallThru(); + +const { fake, assert } = sinon; + +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../utils/constants'); + +const load = () => + proxyquire('../../../../services/thermostat/lib/thermostat.setVariable', { + '../../../utils/logger': { + debug: fake.returns(null), + info: fake.returns(null), + warn: fake.returns(null), + }, + }); + +const buildHandler = () => { + const { setVariable, getVariable, broadcastConfigUpdated, triggerApplySchedules } = load(); + const handler = { + gladys: { + variable: { setValue: fake.resolves({ value: 'saved' }), getValue: fake.resolves('comfort') }, + event: { emit: fake.returns(null) }, + }, + serviceId: 'service-id', + applySchedules: fake.resolves(null), + setVariable, + getVariable, + broadcastConfigUpdated, + triggerApplySchedules, + }; + return handler; +}; + +describe('thermostat.setVariable', () => { + it('should reject a key outside the THERMOSTAT_ namespace', async () => { + const handler = buildHandler(); + + let error = null; + try { + await handler.setVariable('SOME_OTHER_VARIABLE', 'x'); + } catch (e) { + error = e; + } + + expect(error).to.not.equal(null); + expect(error.message).to.contain('Invalid thermostat variable key'); + assert.notCalled(handler.gladys.variable.setValue); + }); + + it('should persist the variable', async () => { + const handler = buildHandler(); + + await handler.setVariable('THERMOSTAT_LIVING_ROOM_PRESET', 'eco'); + + assert.calledWith(handler.gladys.variable.setValue, 'THERMOSTAT_LIVING_ROOM_PRESET', 'eco', 'service-id'); + }); + + it('should refuse a configuration key: the config lives on the device', async () => { + const handler = buildHandler(); + + let error = null; + try { + await handler.setVariable('THERMOSTAT_CONFIG_LIVING_ROOM', '{}'); + } catch (e) { + error = e; + } + + expect(error).to.not.equal(null); + assert.notCalled(handler.gladys.variable.setValue); + }); + + it('should broadcast PRESET_UPDATED for a preset variable', async () => { + const handler = buildHandler(); + + await handler.setVariable('THERMOSTAT_LIVING_ROOM_PRESET', 'night'); + + const [, message] = handler.gladys.event.emit.firstCall.args; + expect(message.type).to.equal(WEBSOCKET_MESSAGE_TYPES.THERMOSTAT.PRESET_UPDATED); + }); + + it('should broadcast MANUAL_MODE_UPDATED for a manual mode variable', async () => { + const handler = buildHandler(); + + await handler.setVariable('THERMOSTAT_LIVING_ROOM_MANUAL_MODE', 'true'); + + const [, message] = handler.gladys.event.emit.firstCall.args; + expect(message.type).to.equal(WEBSOCKET_MESSAGE_TYPES.THERMOSTAT.MANUAL_MODE_UPDATED); + }); + + it('should not broadcast anything for an unrelated thermostat variable', async () => { + const handler = buildHandler(); + + await handler.setVariable('THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL', '123'); + + assert.notCalled(handler.gladys.event.emit); + assert.calledOnce(handler.gladys.variable.setValue); + }); +}); + +describe('thermostat.broadcastConfigUpdated', () => { + it('should tell the dashboards to reload, without carrying the config itself', async () => { + const handler = buildHandler(); + + handler.broadcastConfigUpdated(); + + // An empty payload on purpose: the device is the single store, so a copy + // travelling here could disagree with what the regulation loop reads. + assert.calledWith(handler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.THERMOSTAT.CONFIG_UPDATED, + payload: {}, + }); + }); +}); + +describe('thermostat.getVariable', () => { + it('should read a runtime variable in this service scope', async () => { + const handler = buildHandler(); + + const value = await handler.getVariable('THERMOSTAT_LIVING_ROOM_PRESET'); + + expect(value).to.equal('comfort'); + assert.calledWith(handler.gladys.variable.getValue, 'THERMOSTAT_LIVING_ROOM_PRESET', 'service-id'); + }); + + it('should return null for a key outside the runtime namespace', async () => { + const handler = buildHandler(); + + expect(await handler.getVariable('THERMOSTAT_CONFIG_LIVING_ROOM')).to.equal(null); + assert.notCalled(handler.gladys.variable.getValue); + }); +}); + +describe('thermostat.triggerApplySchedules', () => { + let clock; + + beforeEach(() => { + clock = sinon.useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + + it('should run applySchedules once after the debounce delay', async () => { + const handler = buildHandler(); + + handler.triggerApplySchedules(); + assert.notCalled(handler.applySchedules); + + await clock.tickAsync(2000); + assert.calledOnce(handler.applySchedules); + }); + + it('should collapse a burst of calls into a single run', async () => { + const handler = buildHandler(); + + handler.triggerApplySchedules(); + await clock.tickAsync(500); + handler.triggerApplySchedules(); + await clock.tickAsync(500); + handler.triggerApplySchedules(); + + await clock.tickAsync(2000); + assert.calledOnce(handler.applySchedules); + }); + + it('should swallow an applySchedules failure', async () => { + const handler = buildHandler(); + handler.applySchedules = fake.rejects(new Error('boom')); + + handler.triggerApplySchedules(); + await clock.tickAsync(2000); + + assert.calledOnce(handler.applySchedules); + }); +}); From 7341711f39d638cbebcfec16b6c79c12bd26f00a Mon Sep 17 00:00:00 2001 From: William Deren Date: Sun, 23 Aug 2026 12:50:20 +0200 Subject: [PATCH 04/29] feat(thermostat): declare the dashboard box type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The box carries thermostat_feature and nothing else: it chooses which thermostat to display, never how it is regulated. Every regulation setting is a device param, so a per-user dashboard document — a private one included — can never drive the heating of the house. thermostat_feature is a device-referencing field, so it joins FEATURE_STRING_FIELDS: migrating the thermostat device rewrites the widget's selector instead of leaving it dangling. Co-Authored-By: Claude Opus 5 --- server/lib/device/device.migrate.js | 2 +- server/models/dashboard.js | 4 ++++ server/test/lib/device/device.migrate.test.js | 5 +++++ server/utils/constants.js | 6 ++++++ 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/server/lib/device/device.migrate.js b/server/lib/device/device.migrate.js index ac82f9b873..d592a4b9ff 100644 --- a/server/lib/device/device.migrate.js +++ b/server/lib/device/device.migrate.js @@ -9,7 +9,7 @@ const { getStandardDeviceIncludes } = require('../../utils/deviceQueryIncludes') // Fields carrying selectors in scene actions/triggers and dashboard boxes. // This list is a contract with the Joi schemas of models/scene.js and // models/dashboard.js (see docs/specs/device-migration.md, B.3). -const FEATURE_STRING_FIELDS = ['device_feature']; +const FEATURE_STRING_FIELDS = ['device_feature', 'thermostat_feature']; const FEATURE_ARRAY_FIELDS = ['device_features']; const DEVICE_STRING_FIELDS = ['device', 'camera']; const DEVICE_ARRAY_FIELDS = ['devices']; diff --git a/server/models/dashboard.js b/server/models/dashboard.js index e6b76cf957..1e09d37388 100644 --- a/server/models/dashboard.js +++ b/server/models/dashboard.js @@ -137,6 +137,10 @@ const boxSchema = Joi.object().keys({ .min(0) .max(3600), photo_show_caption: Joi.boolean(), + // thermostat box: the widget only chooses which thermostat to display. + // Every regulation setting (presets, hysteresis, TPI, switch, active schedule) + // lives on the device, so a dashboard can never drive the heating of the house. + thermostat_feature: Joi.string().allow(null), }); // A dashboard is a stack of sections, each section holding its own columns diff --git a/server/test/lib/device/device.migrate.test.js b/server/test/lib/device/device.migrate.test.js index 8d7487106f..57dffbb610 100644 --- a/server/test/lib/device/device.migrate.test.js +++ b/server/test/lib/device/device.migrate.test.js @@ -212,6 +212,7 @@ describe('Device.migrate', function Describe() { type: 'actions', actions: [{ action_type: 'device-feature', device_feature: 'migration-source-temp', value: 1 }], }, + { type: 'thermostat', thermostat_feature: 'migration-source-temp' }, ], ], }); @@ -307,6 +308,10 @@ describe('Device.migrate', function Describe() { type: 'actions', actions: [{ action_type: 'device-feature', device_feature: 'migration-destination-temp', value: 1 }], }, + // thermostat_feature is a device-referencing box field: it must follow + // the migrated feature, otherwise the widget and the regulation loop + // keep pointing at a selector that no longer exists. + { type: 'thermostat', thermostat_feature: 'migration-destination-temp' }, ], ], }, diff --git a/server/utils/constants.js b/server/utils/constants.js index 7c84614d86..678b0f71db 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -1926,6 +1926,11 @@ const WEBSOCKET_MESSAGE_TYPES = { STARTED: 'scene.started', STOPPED: 'scene.stopped', }, + THERMOSTAT: { + CONFIG_UPDATED: 'thermostat.config-updated', + PRESET_UPDATED: 'thermostat.preset-updated', + MANUAL_MODE_UPDATED: 'thermostat.manual-mode-updated', + }, SYSTEM: { VACUUM_FINISHED: 'system.vacuum-finished', WATCHTOWER_LOG: 'system.watchtower-log', @@ -2071,6 +2076,7 @@ const DASHBOARD_BOX_TYPE = { CHIPS: 'chips', HOUSE_VIEW: 'house-view', ACTIONS: 'actions', + THERMOSTAT: 'thermostat', }; const DASHBOARD_WIDTH = { From 43356def3e168b09723e1c3647fb3f501b1370ed Mon Sep 17 00:00:00 2001 From: William Deren Date: Sun, 23 Aug 2026 12:50:37 +0200 Subject: [PATCH 05/29] feat(thermostat): add the dashboard widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A circular gauge showing the current temperature and humidity, the target setpoint, and a preset bar. The dial is draggable to set a temperature by hand; doing so becomes a manual override that holds for thirty minutes, after which the schedule takes over again. The widget reads its configuration from the device rather than from its own box, and shares the slot-matching helper with the server so the banner and the regulation never disagree about which slot is active. Split into focused modules rather than one class: gaugeGeometry.js for pointer-to-setpoint maths, scheduleLookup.js for fetching a schedule and finding the current slot, deviceConfig.js for reading the device params. Under TPI the gauge follows the demand instead of the hysteresis thresholds — the server pulses the heater over a cycle there, so a threshold reading would show the widget idle while it is actually heating. Co-Authored-By: Claude Opus 5 --- .../device-in-room/device-features/style.css | 2 + .../boxs/thermostat/CircularGauge.jsx | 160 +++ .../boxs/thermostat/EditThermostatBox.jsx | 116 ++ .../boxs/thermostat/ThermostatBox.jsx | 1053 +++++++++++++++++ .../boxs/thermostat/deviceConfig.js | 87 ++ .../boxs/thermostat/gaugeGeometry.js | 40 + .../boxs/thermostat/scheduleLookup.js | 67 ++ .../src/components/boxs/thermostat/style.css | 478 ++++++++ front/src/routes/dashboard/Box.jsx | 3 + .../dashboard/edit-dashboard/EditBox.jsx | 3 + .../routes/dashboard/edit-dashboard/style.css | 6 + front/src/utils/thermostatPresetColors.js | 29 + 12 files changed, 2044 insertions(+) create mode 100644 front/src/components/boxs/thermostat/CircularGauge.jsx create mode 100644 front/src/components/boxs/thermostat/EditThermostatBox.jsx create mode 100644 front/src/components/boxs/thermostat/ThermostatBox.jsx create mode 100644 front/src/components/boxs/thermostat/deviceConfig.js create mode 100644 front/src/components/boxs/thermostat/gaugeGeometry.js create mode 100644 front/src/components/boxs/thermostat/scheduleLookup.js create mode 100644 front/src/components/boxs/thermostat/style.css create mode 100644 front/src/utils/thermostatPresetColors.js diff --git a/front/src/components/boxs/device-in-room/device-features/style.css b/front/src/components/boxs/device-in-room/device-features/style.css index 4a27efcbcd..7f09f6fbb6 100644 --- a/front/src/components/boxs/device-in-room/device-features/style.css +++ b/front/src/components/boxs/device-in-room/device-features/style.css @@ -23,12 +23,14 @@ input[type='range'][class~='light-temperature']::-ms-fill-lower { .removeNumberArrow::-webkit-outer-spin-button, .removeNumberArrow::-webkit-inner-spin-button { -webkit-appearance: none; + appearance: none; margin: 0; } /* Firefox */ .removeNumberArrow { -moz-appearance: textfield; + appearance: textfield; } .setpointHorizontalControls { diff --git a/front/src/components/boxs/thermostat/CircularGauge.jsx b/front/src/components/boxs/thermostat/CircularGauge.jsx new file mode 100644 index 0000000000..6186ff7068 --- /dev/null +++ b/front/src/components/boxs/thermostat/CircularGauge.jsx @@ -0,0 +1,160 @@ +import style from './style.css'; + +// The gauge is drawn as an SVG arc spanning ARC_DEGREES, opening at the bottom: +// it starts at ARC_START_ANGLE (150°, lower-left) and sweeps clockwise. +export const ARC_DEGREES = 240; +export const ARC_START_ANGLE = 150; + +/** + * Convert a polar coordinate (angle in degrees, 0 = 12 o'clock) to cartesian. + */ +function polarToCartesian(cx, cy, r, angleDeg) { + const rad = ((angleDeg - 90) * Math.PI) / 180; + return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }; +} + +function describeArc(cx, cy, r, startAngle, endAngle) { + const start = polarToCartesian(cx, cy, r, startAngle); + const end = polarToCartesian(cx, cy, r, endAngle); + const largeArc = endAngle - startAngle > 180 ? '1' : '0'; + return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y}`; +} + +const CircularGauge = ({ + setpoint, + currentTemp, + humidity, + onPointerDown, + onIncrement, + onDecrement, + minTemp, + maxTemp, + mode, + isActive, + isWindowOpen, + tempUnit +}) => { + const cx = 110; + const cy = 110; + const r = 88; + const sw = 11; + const range = maxTemp - minTemp; + const pct = range === 0 ? 0.5 : Math.min(1, Math.max(0, (setpoint - minTemp) / range)); + const arcEnd = ARC_START_ANGLE + Math.max(pct, 0.001) * ARC_DEGREES; + const bgPath = describeArc(cx, cy, r, ARC_START_ANGLE, ARC_START_ANGLE + ARC_DEGREES); + const fgPath = describeArc(cx, cy, r, ARC_START_ANGLE, arcEnd); + const knob = polarToCartesian(cx, cy, r, arcEnd); + const arcColor = mode === 'cooling' ? '#3b82f6' : mode === 'off' ? '#adb5bd' : '#f97316'; + // Derive both halves from one rounded value: splitting the raw setpoint made + // 20.96 render as "20.10" (the decimal carried to 10) and -3.5 as "-4.5" + // (floor rounds away from zero for negatives). + const roundedSetpoint = Math.round(setpoint * 10) / 10; + const truncated = Math.trunc(roundedSetpoint); + const decPart = Math.round(Math.abs(roundedSetpoint - truncated) * 10); + // Math.trunc(-0.5) is -0, which renders as "0": a setpoint between -1 and 0 + // would lose its sign, so the minus is restored explicitly. + const intPart = truncated === 0 && roundedSetpoint < 0 ? '-0' : String(truncated); + const intW = intPart.length * 30; + const intX = cx - intW / 2 - 18; + const suffixX = intX + intW; + + const hasCurrentTemp = currentTemp !== null && currentTemp !== undefined; + const hasHumidity = humidity !== null && humidity !== undefined; + + return ( + + + {/* The glow marks "running right now", which is just as true of a running + air conditioner as of a running heater, so it applies in both modes. + It is a drop-shadow rather than a feGaussianBlur/feMerge filter: merging + a blurred copy under the stroke softens the stroke's own edges, which on + this pale background turned the blue arc into a grey smear. A shadow + leaves the stroke untouched and only casts colour around it. */} + + + + {/* Current temp + humidity: above setpoint */} + {hasCurrentTemp && ( + + {Number(currentTemp).toFixed(1)} °{tempUnit || 'C'} + + )} + {hasHumidity && ( + + {`\u{1F4A7} ${Math.round(humidity)} %`} + + )} + + {/* Setpoint: integer + decimal + unit split (° above dot, C above decimal) */} + + {intPart} + + + .{decPart} + + + ° + + + {tempUnit || 'C'} + + + {/* Active icon: at bottom of gauge */} + {isWindowOpen && ( + + 🪟 + + )} + {!isWindowOpen && isActive && mode === 'heating' && ( + + 🔥 + + )} + {!isWindowOpen && isActive && mode === 'cooling' && ( + + ❄️ + + )} + + {onIncrement && ( + e.stopPropagation()} class={style.arcBtnGroup}> + + + + + + + )} + {onDecrement && ( + e.stopPropagation()} class={style.arcBtnGroup}> + + + − + + + )} + + ); +}; + +export default CircularGauge; diff --git a/front/src/components/boxs/thermostat/EditThermostatBox.jsx b/front/src/components/boxs/thermostat/EditThermostatBox.jsx new file mode 100644 index 0000000000..352cd749ed --- /dev/null +++ b/front/src/components/boxs/thermostat/EditThermostatBox.jsx @@ -0,0 +1,116 @@ +import { Component } from 'preact'; +import { Text } from 'preact-i18n'; +import { connect } from 'unistore/preact'; +import Select from 'react-select'; +import { getDeviceFeatureName } from '../../../utils/device'; +import withIntlAsProp from '../../../utils/withIntlAsProp'; +import BaseEditBox from '../baseEditBox'; +import { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } from '../../../../../server/utils/constants'; + +const SELECT_STYLES = { + valueContainer: provided => ({ ...provided, paddingLeft: '8px' }), + input: provided => ({ ...provided, paddingLeft: '4px' }), + placeholder: provided => ({ ...provided, paddingLeft: '4px' }), + singleValue: provided => ({ ...provided, marginLeft: '0px', paddingLeft: '4px' }) +}; + +class EditThermostatBoxComponent extends Component { + updateName = e => { + this.props.updateBoxConfig(this.props.x, this.props.y, { name: e.target.value || undefined }); + }; + + updateThermostatFeature = option => { + this.props.updateBoxConfig(this.props.x, this.props.y, { thermostat_feature: option ? option.value : null }); + this.setState({ selectedThermostatOption: option || null }); + }; + + buildOptions = devices => { + const options = []; + devices.forEach(device => { + const featureOptions = []; + device.features.forEach(feature => { + // Only the setpoints created by this integration: the widget drives the + // thermostat service, which cannot regulate a feature it does not own. + if ( + feature.category !== DEVICE_FEATURE_CATEGORIES.THERMOSTAT || + feature.type !== DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE + ) { + return; + } + featureOptions.push({ + value: feature.selector, + label: getDeviceFeatureName(this.props.intl.dictionary, device, feature) + }); + }); + if (featureOptions.length > 0) { + options.push({ label: device.name, options: featureOptions }); + } + }); + return options; + }; + + getDevices = async () => { + try { + const devices = await this.props.httpClient.get('/api/v1/service/thermostat/device'); + const thermostatOptions = this.buildOptions(devices); + let selectedThermostatOption = null; + thermostatOptions.forEach(group => + group.options.forEach(opt => { + if (opt.value === this.props.box.thermostat_feature) selectedThermostatOption = opt; + }) + ); + this.setState({ thermostatOptions, selectedThermostatOption }); + } catch (e) { + this.setState({ thermostatOptions: [] }); + } + }; + + componentDidMount() { + this.getDevices(); + } + + render(props, { thermostatOptions, selectedThermostatOption }) { + const t = props.intl && props.intl.dictionary && props.intl.dictionary.dashboard.boxes.thermostat; + const placeholder = (t && t.selectPlaceholder) || ''; + + return ( + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + ); + } +} + +export default ThermostatDeviceBox; diff --git a/front/src/routes/integration/all/thermostat/device-page/actions.js b/front/src/routes/integration/all/thermostat/device-page/actions.js new file mode 100644 index 0000000000..4f70341478 --- /dev/null +++ b/front/src/routes/integration/all/thermostat/device-page/actions.js @@ -0,0 +1,99 @@ +import { RequestStatus } from '../../../../../utils/consts'; +import update from 'immutability-helper'; +import debounce from 'debounce'; +import createActionsHouse from '../../../../../actions/house'; + +function createActions(store) { + const houseActions = createActionsHouse(store); + const actions = { + async getThermostatDevices(state) { + store.setState({ getThermostatDevicesStatus: RequestStatus.Getting }); + try { + const options = { + order_dir: state.getThermostatDeviceOrderDir || 'asc' + }; + if (state.thermostatDeviceSearch && state.thermostatDeviceSearch.length) { + options.search = state.thermostatDeviceSearch; + } + const allDevices = await state.httpClient.get('/api/v1/service/thermostat/device', options); + const filtered = Array.isArray(allDevices) ? allDevices : []; + // The active schedule is a device param, so it comes back with the device: + // no extra variable round-trip per thermostat. + const enriched = filtered.map(device => { + const param = (device.params || []).find(p => p.name === 'THERMOSTAT_ACTIVE_SCHEDULE'); + return { ...device, active_schedule: (param && param.value) || '' }; + }); + store.setState({ + thermostatDevices: enriched, + getThermostatDevicesStatus: RequestStatus.Success + }); + } catch (e) { + store.setState({ + thermostatDevices: [], + getThermostatDevicesStatus: RequestStatus.Error + }); + } + }, + async saveDevice(state, device, index) { + const { active_schedule, ...deviceToSave } = device; + // Persist the schedule as a device param rather than a global variable. + const otherParams = (deviceToSave.params || []).filter(p => p.name !== 'THERMOSTAT_ACTIVE_SCHEDULE'); + deviceToSave.params = [...otherParams, { name: 'THERMOSTAT_ACTIVE_SCHEDULE', value: active_schedule || '' }]; + const savedDevice = await state.httpClient.post('/api/v1/device', deviceToSave); + // Read the schedule back from what the server actually stored rather than + // from the form value: the widget derives its banner from this param, and + // showing an unsaved value would make it disagree with the regulation. + const savedParam = (savedDevice.params || []).find(p => p.name === 'THERMOSTAT_ACTIVE_SCHEDULE'); + const savedSchedule = savedParam ? savedParam.value : active_schedule || ''; + const newState = update(state, { + thermostatDevices: { + $splice: [[index, 1, { ...savedDevice, active_schedule: savedSchedule }]] + } + }); + store.setState(newState); + // Apply the new schedule now instead of waiting for the next minute tick. + try { + await state.httpClient.post('/api/v1/service/thermostat/apply-schedules', {}); + } catch (e) { + // The regulation loop picks it up within a minute anyway. + } + }, + updateDeviceProperty(state, index, property, value) { + const newState = update(state, { + thermostatDevices: { + [index]: { + [property]: { $set: value } + } + } + }); + store.setState(newState); + }, + async getSchedules(state) { + try { + const schedules = await state.httpClient.get('/api/v1/service/thermostat/schedule'); + store.setState({ thermostatSchedules: Array.isArray(schedules) ? schedules : [] }); + } catch (e) { + store.setState({ thermostatSchedules: [] }); + } + }, + async deleteDevice(state, device, index) { + await state.httpClient.delete(`/api/v1/device/${device.selector}`); + const newState = update(state, { + thermostatDevices: { $splice: [[index, 1]] } + }); + store.setState(newState); + }, + async search(state, e) { + await store.setState({ thermostatDeviceSearch: e.target.value }); + actions.debouncedGetThermostatDevices(store.getState()); + }, + async changeOrderDir(state, e) { + store.setState({ getThermostatDeviceOrderDir: e.target.value }); + await actions.getThermostatDevices(store.getState()); + } + }; + actions.debouncedGetThermostatDevices = debounce(actions.getThermostatDevices, 200); + return Object.assign({}, houseActions, actions); +} + +export default createActions; diff --git a/front/src/routes/integration/all/thermostat/device-page/index.js b/front/src/routes/integration/all/thermostat/device-page/index.js new file mode 100644 index 0000000000..1fd64d12f9 --- /dev/null +++ b/front/src/routes/integration/all/thermostat/device-page/index.js @@ -0,0 +1,26 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import actions from './actions'; +import ThermostatPage from '../ThermostatPage'; +import DeviceTab from './DeviceTab'; + +class ThermostatDevicePage extends Component { + componentWillMount() { + this.props.getThermostatDevices(); + this.props.getHouses(); + this.props.getSchedules(); + } + + render(props) { + return ( + + + + ); + } +} + +export default connect( + 'user,houses,thermostatDevices,getThermostatDevicesStatus,thermostatDeviceSearch,getThermostatDeviceOrderDir,thermostatSchedules', + actions +)(ThermostatDevicePage); diff --git a/front/src/routes/integration/all/thermostat/device-page/style.css b/front/src/routes/integration/all/thermostat/device-page/style.css new file mode 100644 index 0000000000..a1f7c749db --- /dev/null +++ b/front/src/routes/integration/all/thermostat/device-page/style.css @@ -0,0 +1,4 @@ +.buttonGroup { + display: flex; + gap: 8px; +} diff --git a/front/src/routes/integration/all/thermostat/edit-page/EditForm.jsx b/front/src/routes/integration/all/thermostat/edit-page/EditForm.jsx new file mode 100644 index 0000000000..6b63f7a885 --- /dev/null +++ b/front/src/routes/integration/all/thermostat/edit-page/EditForm.jsx @@ -0,0 +1,551 @@ +import { Text, Localizer } from 'preact-i18n'; +import cx from 'classnames'; +import { RequestStatus } from '../../../../../utils/consts'; +import style from './style.css'; +import { getPresetColor } from '../../../../../utils/thermostatPresetColors'; + +const FeatureSelect = ({ value, features, onChange, emptyLabel }) => ( + +); + +const EditForm = ({ ...props }) => { + const saving = props.thermostatCreateStatus === RequestStatus.Getting; + const isEdit = !!(props.thermostatEditDevice && props.thermostatEditDevice.selector); + const mode = props.thermostatEditMode || 'heating'; + // The control rules read the other way round in cooling: the switch turns on + // when the room is too warm. The help texts are therefore per-mode, not a + // heating text with the word swapped. + const modeSuffix = mode === 'cooling' ? 'cooling' : 'heating'; + + const heatingPresets = ['frost', 'away', 'eco', 'night', 'comfort']; + const coolingPresets = ['comfort']; + const activePresets = mode === 'cooling' ? coolingPresets : heatingPresets; + + const presetFields = { + frost: 'thermostatEditPresetFrost', + away: 'thermostatEditPresetAway', + eco: 'thermostatEditPresetEco', + night: 'thermostatEditPresetNight', + comfort: 'thermostatEditPresetComfort' + }; + + const controlType = props.thermostatEditControlType || 'hysteresis'; + + return ( +
+
+

+ {isEdit ? ( + + ) : ( + + )} +

+
+
+
+
+
+ {props.thermostatCreateStatus === RequestStatus.Error && ( +
+ +
+ )} + + {/* Nom */} +
+ + + } + value={props.thermostatEditName} + onInput={e => props.updateThermostatField('thermostatEditName', e.target.value)} + /> + +
+ + {/* Pièce */} +
+ + +
+ + {/* Mode */} +
+ + +
+ + {/* Type de calcul + paramètres associés */} +
+ + + + {controlType === 'tpi' ? ( + + + + + {' — '} + + + ) : ( + + + + + {' — '} + + + )} + +
+ + {/* Paramètres hystérésis */} + {controlType === 'hysteresis' && ( +
+
+
+ +
+ props.updateThermostatField('thermostatEditHysteresisStart', e.target.value)} + /> +
+ + {(props.thermostatEditTempUnit || 'C') === 'F' ? '°F' : '°C'} + +
+
+ + + +
+
+
+
+ +
+ props.updateThermostatField('thermostatEditHysteresisStop', e.target.value)} + /> +
+ + {(props.thermostatEditTempUnit || 'C') === 'F' ? '°F' : '°C'} + +
+
+ + + +
+
+
+ )} + + {/* Paramètres TPI */} + {controlType === 'tpi' && ( +
+
+
+ +
+ props.updateThermostatField('thermostatEditTpiCycleTime', e.target.value)} + /> +
+ min +
+
+ + + +
+
+
+
+ +
+ props.updateThermostatField('thermostatEditTpiProportionalBand', e.target.value)} + /> +
+ + {(props.thermostatEditTempUnit || 'C') === 'F' ? '°F' : '°C'} + +
+
+ + + +
+
+
+ )} + + {/* Unité + Plage de température */} +
+
+
+ + +
+
+
+
+ +
+ + } + value={props.thermostatEditMinTemp} + onInput={e => props.updateThermostatField('thermostatEditMinTemp', e.target.value)} + /> + +
+ + {(props.thermostatEditTempUnit || 'C') === 'F' ? '°F' : '°C'} + +
+
+
+
+
+
+ +
+ + } + value={props.thermostatEditMaxTemp} + onInput={e => props.updateThermostatField('thermostatEditMaxTemp', e.target.value)} + /> + +
+ + {(props.thermostatEditTempUnit || 'C') === 'F' ? '°F' : '°C'} + +
+
+
+
+
+ + {/* Capteur de température */} +
+ + + props.updateThermostatField('thermostatEditTemperatureFeature', e.target.value)} + emptyLabel={} + /> + + + + +
+ + {/* Capteur d'humidité */} +
+ + + props.updateThermostatField('thermostatEditHumidityFeature', e.target.value)} + emptyLabel={} + /> + + + + +
+ + {/* Commutateur */} +
+ + + props.updateThermostatField('thermostatEditSwitchFeature', e.target.value)} + emptyLabel={} + /> + + + + +
+ + {/* Capteur d'ouverture de fenêtre */} +
+ + + props.updateThermostatField('thermostatEditWindowFeature', e.target.value)} + emptyLabel={} + /> + + + + +
+ + {/* Presets : nom + couleur fixe + température */} +
+ + + + + + + + + + {['off', ...activePresets].map(key => ( + + + + + ))} + +
+ + + +
+ + + + {presetFields[key] ? ( +
+ props.updateThermostatField(presetFields[key], e.target.value)} + step="0.5" + /> +
+ + {(props.thermostatEditTempUnit || 'C') === 'F' ? '°F' : '°C'} + +
+
+ ) : ( + + )} +
+
+ + {/* Planning actif */} +
+ + + + + +
+ + {/* Durée mode manuel */} +
+ +
+ props.updateThermostatField('thermostatEditManualDuration', e.target.value)} + step="1" + /> +
+ + + +
+
+ + + +
+ +
+
+ + + + +
+
+
+
+
+
+ ); +}; + +export default EditForm; diff --git a/front/src/routes/integration/all/thermostat/edit-page/actions.js b/front/src/routes/integration/all/thermostat/edit-page/actions.js new file mode 100644 index 0000000000..86d2af4e8d --- /dev/null +++ b/front/src/routes/integration/all/thermostat/edit-page/actions.js @@ -0,0 +1,259 @@ +import { RequestStatus } from '../../../../../utils/consts'; +import { DEVICE_FEATURE_CATEGORIES } from '../../../../../../../server/utils/constants'; +import { route } from 'preact-router'; +import createActionsHouse from '../../../../../actions/house'; + +const TEMPERATURE_CATEGORIES = [DEVICE_FEATURE_CATEGORIES.TEMPERATURE_SENSOR]; +const HUMIDITY_CATEGORIES = [DEVICE_FEATURE_CATEGORIES.HUMIDITY_SENSOR]; +const SWITCH_CATEGORIES = [DEVICE_FEATURE_CATEGORIES.SWITCH]; +const OPENING_CATEGORIES = [DEVICE_FEATURE_CATEGORIES.OPENING_SENSOR]; + +function createActions(store) { + const houseActions = createActionsHouse(store); + const actions = { + async getSchedules(state) { + try { + const schedules = await state.httpClient.get('/api/v1/service/thermostat/schedule'); + store.setState({ thermostatSchedules: schedules }); + } catch (e) { + store.setState({ thermostatSchedules: [] }); + } + }, + + async getDevicesForThermostatEdit(state) { + try { + const devices = await state.httpClient.get('/api/v1/device'); + const temperatureFeatures = []; + const humidityFeatures = []; + const switchFeatures = []; + const openingFeatures = []; + devices.forEach(device => { + device.features.forEach(feature => { + const entry = { selector: feature.selector, label: `${device.name} - ${feature.name}` }; + if (TEMPERATURE_CATEGORIES.includes(feature.category)) { + temperatureFeatures.push(entry); + } + if (HUMIDITY_CATEGORIES.includes(feature.category)) { + humidityFeatures.push(entry); + } + if (SWITCH_CATEGORIES.includes(feature.category) && feature.type === 'binary') { + switchFeatures.push(entry); + } + if (OPENING_CATEGORIES.includes(feature.category)) { + openingFeatures.push(entry); + } + }); + }); + store.setState({ temperatureFeatures, humidityFeatures, switchFeatures, openingFeatures }); + } catch (e) { + store.setState({ temperatureFeatures: [], humidityFeatures: [], switchFeatures: [] }); + } + }, + + async getThermostatDevice(state, selector) { + store.setState({ getThermostatDeviceStatus: RequestStatus.Getting }); + try { + const device = await state.httpClient.get(`/api/v1/device/${selector}`); + const getParam = name => { + const p = (device.params || []).find(x => x.name === name); + return p ? p.value : null; + }; + store.setState({ + thermostatEditDevice: device, + thermostatEditName: device.name, + thermostatEditMode: getParam('THERMOSTAT_MODE') || 'heating', + thermostatEditMinTemp: getParam('THERMOSTAT_MIN_TEMP') || '5', + thermostatEditMaxTemp: getParam('THERMOSTAT_MAX_TEMP') || '35', + thermostatEditTempUnit: getParam('THERMOSTAT_TEMP_UNIT') || 'C', + thermostatEditControlType: getParam('THERMOSTAT_CONTROL_TYPE') || 'hysteresis', + thermostatEditActiveSchedule: getParam('THERMOSTAT_ACTIVE_SCHEDULE') || '', + thermostatEditTemperatureFeature: getParam('THERMOSTAT_TEMPERATURE_FEATURE') || '', + thermostatEditHumidityFeature: getParam('THERMOSTAT_HUMIDITY_FEATURE') || '', + thermostatEditSwitchFeature: getParam('THERMOSTAT_SWITCH_FEATURE') || '', + thermostatEditWindowFeature: getParam('THERMOSTAT_WINDOW_FEATURE') || '', + thermostatEditPresetFrost: getParam('THERMOSTAT_PRESET_FROST') || '7', + thermostatEditPresetAway: getParam('THERMOSTAT_PRESET_AWAY') || '16', + thermostatEditPresetEco: getParam('THERMOSTAT_PRESET_ECO') || '18', + thermostatEditPresetNight: getParam('THERMOSTAT_PRESET_NIGHT') || '17', + thermostatEditPresetComfort: getParam('THERMOSTAT_PRESET_COMFORT') || '21', + thermostatEditHysteresisStart: getParam('THERMOSTAT_HYSTERESIS_START') || '0.5', + thermostatEditHysteresisStop: getParam('THERMOSTAT_HYSTERESIS_STOP') || '0.5', + thermostatEditTpiCycleTime: getParam('THERMOSTAT_TPI_CYCLE_TIME') || '30', + thermostatEditTpiProportionalBand: getParam('THERMOSTAT_TPI_PROPORTIONAL_BAND') || '2', + thermostatEditRoomId: device.room_id || '', + thermostatEditManualDuration: getParam('THERMOSTAT_MANUAL_DURATION') || '30', + getThermostatDeviceStatus: RequestStatus.Success + }); + } catch (e) { + store.setState({ getThermostatDeviceStatus: RequestStatus.Error }); + } + }, + + updateThermostatField(state, field, value) { + store.setState({ [field]: value }); + }, + + updateThermostatUnit(state, newUnit) { + const oldUnit = state.thermostatEditTempUnit || 'C'; + if (oldUnit === newUnit) return; + const isSet = v => v !== '' && v !== null && v !== undefined; + const round = v => String(Math.round(v * 2) / 2); + // Absolute temperatures (setpoints, min/max) carry the 32° offset... + const toF = v => (isSet(v) ? round((parseFloat(v) * 9) / 5 + 32) : v); + const toC = v => (isSet(v) ? round(((parseFloat(v) - 32) * 5) / 9) : v); + // ...but hysteresis and the TPI band are temperature *differences*: adding + // the offset would turn a 0.5 °C hysteresis into 32.9 °F. + const deltaToF = v => (isSet(v) ? round((parseFloat(v) * 9) / 5) : v); + const deltaToC = v => (isSet(v) ? round((parseFloat(v) * 5) / 9) : v); + const conv = newUnit === 'F' ? toF : toC; + const convDelta = newUnit === 'F' ? deltaToF : deltaToC; + store.setState({ + thermostatEditTempUnit: newUnit, + thermostatEditMinTemp: conv(state.thermostatEditMinTemp), + thermostatEditMaxTemp: conv(state.thermostatEditMaxTemp), + thermostatEditPresetFrost: conv(state.thermostatEditPresetFrost), + thermostatEditPresetAway: conv(state.thermostatEditPresetAway), + thermostatEditPresetEco: conv(state.thermostatEditPresetEco), + thermostatEditPresetNight: conv(state.thermostatEditPresetNight), + thermostatEditPresetComfort: conv(state.thermostatEditPresetComfort), + thermostatEditHysteresisStart: convDelta(state.thermostatEditHysteresisStart), + thermostatEditHysteresisStop: convDelta(state.thermostatEditHysteresisStop), + thermostatEditTpiProportionalBand: convDelta(state.thermostatEditTpiProportionalBand) + }); + }, + + async saveThermostatDevice(state) { + store.setState({ thermostatCreateStatus: RequestStatus.Getting }); + try { + // `parseFloat(x) || d` turns a legitimate 0 into the default, so a 0 °C + // hysteresis band could never be saved. Fall back only when the input is + // not a finite number, like the server-side `toNumber` helper does. + const toNumber = (value, defaultValue) => { + const parsed = parseFloat(value); + return Number.isFinite(parsed) ? parsed : defaultValue; + }; + const toInt = (value, defaultValue) => { + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : defaultValue; + }; + + const name = state.thermostatEditName || 'Thermostat'; + const mode = state.thermostatEditMode || 'heating'; + const minTemp = toNumber(state.thermostatEditMinTemp, 5); + const maxTemp = toNumber(state.thermostatEditMaxTemp, 35); + const tempUnit = state.thermostatEditTempUnit || 'C'; + const controlType = state.thermostatEditControlType || 'hysteresis'; + const temperatureFeature = state.thermostatEditTemperatureFeature || ''; + const humidityFeature = state.thermostatEditHumidityFeature || ''; + const switchFeature = state.thermostatEditSwitchFeature || ''; + const windowFeature = state.thermostatEditWindowFeature || ''; + const presetFrost = state.thermostatEditPresetFrost || '7'; + const presetAway = state.thermostatEditPresetAway || '16'; + const presetEco = state.thermostatEditPresetEco || '18'; + const presetNight = state.thermostatEditPresetNight || '17'; + const presetComfort = state.thermostatEditPresetComfort || '21'; + const hysteresisStart = toNumber(state.thermostatEditHysteresisStart, 0.5); + const hysteresisStop = toNumber(state.thermostatEditHysteresisStop, 0.5); + const tpiCycleTime = toInt(state.thermostatEditTpiCycleTime, 30); + const tpiProportionalBand = toNumber(state.thermostatEditTpiProportionalBand, 2); + const manualDuration = toInt(state.thermostatEditManualDuration, 30); + + const isEdit = !!(state.thermostatEditDevice && state.thermostatEditDevice.selector); + const timestamp = Date.now(); + const slugName = name.toLowerCase().replace(/[^a-z0-9]/g, '-'); + const newExternalId = `thermostat:${slugName}-${timestamp}`; + + const device = { + name, + external_id: isEdit ? state.thermostatEditDevice.external_id : newExternalId, + selector: isEdit ? state.thermostatEditDevice.selector : undefined, + should_poll: false, + features: [ + { + // The thermostat/target-temperature category already means "setpoint" + // in every language; a hardcoded French suffix would leak into the + // device name shown in scenes, MQTT and every UI. + name, + external_id: isEdit + ? `${state.thermostatEditDevice.external_id}:target-temperature` + : `${newExternalId}:target-temperature`, + category: 'thermostat', + type: 'target-temperature', + read_only: false, + keep_history: true, + has_feedback: false, + min: minTemp, + max: maxTemp, + unit: tempUnit === 'F' ? 'fahrenheit' : 'celsius' + } + ], + room_id: state.thermostatEditRoomId || undefined, + params: [ + // The active schedule is device-owned: the dashboard widget only + // chooses which thermostat to display, it never drives regulation. + { name: 'THERMOSTAT_ACTIVE_SCHEDULE', value: state.thermostatEditActiveSchedule || '' }, + { name: 'THERMOSTAT_MODE', value: mode }, + { name: 'THERMOSTAT_MIN_TEMP', value: String(minTemp) }, + { name: 'THERMOSTAT_MAX_TEMP', value: String(maxTemp) }, + { name: 'THERMOSTAT_TEMP_UNIT', value: tempUnit }, + { name: 'THERMOSTAT_CONTROL_TYPE', value: controlType }, + { name: 'THERMOSTAT_TEMPERATURE_FEATURE', value: temperatureFeature }, + { name: 'THERMOSTAT_HUMIDITY_FEATURE', value: humidityFeature }, + { name: 'THERMOSTAT_SWITCH_FEATURE', value: switchFeature }, + { name: 'THERMOSTAT_WINDOW_FEATURE', value: windowFeature }, + { name: 'THERMOSTAT_PRESET_FROST', value: presetFrost }, + { name: 'THERMOSTAT_PRESET_AWAY', value: presetAway }, + { name: 'THERMOSTAT_PRESET_ECO', value: presetEco }, + { name: 'THERMOSTAT_PRESET_NIGHT', value: presetNight }, + { name: 'THERMOSTAT_PRESET_COMFORT', value: presetComfort }, + { name: 'THERMOSTAT_HYSTERESIS_START', value: String(hysteresisStart) }, + { name: 'THERMOSTAT_HYSTERESIS_STOP', value: String(hysteresisStop) }, + { name: 'THERMOSTAT_TPI_CYCLE_TIME', value: String(tpiCycleTime) }, + { name: 'THERMOSTAT_TPI_PROPORTIONAL_BAND', value: String(tpiProportionalBand) }, + { name: 'THERMOSTAT_MANUAL_DURATION', value: String(manualDuration) } + ] + }; + + // The device is the single store for the configuration: every field above + // is a device param. Writing a THERMOSTAT_CONFIG_* variable as well would + // reintroduce two sources of truth for the same settings, and a failure + // between the two writes would leave them disagreeing. + await state.httpClient.post('/api/v1/service/thermostat/device', device); + + store.setState({ + thermostatCreateStatus: RequestStatus.Success, + thermostatEditDevice: null, + thermostatEditName: '', + thermostatEditMode: 'heating', + thermostatEditMinTemp: '5', + thermostatEditMaxTemp: '35', + thermostatEditTempUnit: 'C', + thermostatEditControlType: 'hysteresis', + thermostatEditTemperatureFeature: '', + thermostatEditHumidityFeature: '', + thermostatEditSwitchFeature: '', + thermostatEditWindowFeature: '', + thermostatEditActiveSchedule: '', + thermostatEditPresetFrost: '7', + thermostatEditPresetAway: '16', + thermostatEditPresetEco: '18', + thermostatEditPresetNight: '17', + thermostatEditPresetComfort: '21', + thermostatEditHysteresisStart: '0.5', + thermostatEditHysteresisStop: '0.5', + thermostatEditTpiCycleTime: '30', + thermostatEditTpiProportionalBand: '2', + thermostatEditRoomId: '', + thermostatEditManualDuration: '30' + }); + route('/dashboard/integration/device/thermostat'); + } catch (e) { + store.setState({ thermostatCreateStatus: RequestStatus.Error }); + } + } + }; + + return Object.assign({}, houseActions, actions); +} + +export default createActions; diff --git a/front/src/routes/integration/all/thermostat/edit-page/index.js b/front/src/routes/integration/all/thermostat/edit-page/index.js new file mode 100644 index 0000000000..276896f8cf --- /dev/null +++ b/front/src/routes/integration/all/thermostat/edit-page/index.js @@ -0,0 +1,67 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import actions from './actions'; +import ThermostatPage from '../ThermostatPage'; +import EditForm from './EditForm'; + +class ThermostatEditPage extends Component { + // Extracted so a selector change on a reused route reloads the form. Doing this + // only in componentWillMount left the previous device's values on screen when + // navigating from one thermostat's edit page to another's. + loadForSelector(deviceSelector) { + if (deviceSelector) { + this.props.getThermostatDevice(deviceSelector); + return; + } + this.props.updateThermostatField('thermostatEditDevice', null); + this.props.updateThermostatField('thermostatEditName', ''); + this.props.updateThermostatField('thermostatEditMode', 'heating'); + this.props.updateThermostatField('thermostatEditMinTemp', '5'); + this.props.updateThermostatField('thermostatEditMaxTemp', '35'); + this.props.updateThermostatField('thermostatEditTempUnit', 'C'); + this.props.updateThermostatField('thermostatEditControlType', 'hysteresis'); + this.props.updateThermostatField('thermostatEditActiveSchedule', ''); + this.props.updateThermostatField('thermostatEditTemperatureFeature', ''); + this.props.updateThermostatField('thermostatEditHumidityFeature', ''); + this.props.updateThermostatField('thermostatEditSwitchFeature', ''); + this.props.updateThermostatField('thermostatEditWindowFeature', ''); + this.props.updateThermostatField('thermostatEditPresetFrost', '7'); + this.props.updateThermostatField('thermostatEditPresetAway', '16'); + this.props.updateThermostatField('thermostatEditPresetEco', '18'); + this.props.updateThermostatField('thermostatEditPresetNight', '17'); + this.props.updateThermostatField('thermostatEditPresetComfort', '21'); + this.props.updateThermostatField('thermostatEditHysteresisStart', '0.5'); + this.props.updateThermostatField('thermostatEditHysteresisStop', '0.5'); + this.props.updateThermostatField('thermostatEditTpiCycleTime', '30'); + this.props.updateThermostatField('thermostatEditTpiProportionalBand', '2'); + this.props.updateThermostatField('thermostatEditRoomId', ''); + this.props.updateThermostatField('thermostatEditManualDuration', '30'); + this.props.updateThermostatField('thermostatCreateStatus', null); + } + + componentWillMount() { + this.props.getDevicesForThermostatEdit(); + this.props.getHouses(); + this.props.getSchedules(); + this.loadForSelector(this.props.deviceSelector); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.deviceSelector !== this.props.deviceSelector) { + this.loadForSelector(nextProps.deviceSelector); + } + } + + render(props) { + return ( + + + + ); + } +} + +export default connect( + 'user,houses,thermostatEditDevice,thermostatEditName,thermostatEditMode,thermostatEditMinTemp,thermostatEditMaxTemp,thermostatEditTempUnit,thermostatEditControlType,thermostatEditTemperatureFeature,thermostatEditHumidityFeature,thermostatEditSwitchFeature,thermostatEditWindowFeature,thermostatEditPresetFrost,thermostatEditPresetAway,thermostatEditPresetEco,thermostatEditPresetNight,thermostatEditPresetComfort,thermostatEditHysteresisStart,thermostatEditHysteresisStop,thermostatEditTpiCycleTime,thermostatEditTpiProportionalBand,thermostatEditRoomId,thermostatEditManualDuration,thermostatEditActiveSchedule,thermostatSchedules,thermostatCreateStatus,temperatureFeatures,humidityFeatures,switchFeatures,openingFeatures', + actions +)(ThermostatEditPage); diff --git a/front/src/routes/integration/all/thermostat/edit-page/style.css b/front/src/routes/integration/all/thermostat/edit-page/style.css new file mode 100644 index 0000000000..7498e41b41 --- /dev/null +++ b/front/src/routes/integration/all/thermostat/edit-page/style.css @@ -0,0 +1,24 @@ +.presetColName { + width: 30%; +} + +.presetTempInput { + max-width: 90px; +} + +/* Preset colour swatch: only the colour itself is dynamic, passed as a CSS + variable. The dark theme inverts the whole page, so re-invert the dot to + keep its real colour — same handling as the schedule editor dot. */ +.presetColorDot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin-right: 6px; + flex-shrink: 0; + background: var(--dot-color, #adb5bd); +} + +:global(.dark-mode) .presetColorDot { + filter: invert(100%) hue-rotate(180deg); +} diff --git a/front/src/routes/integration/all/thermostat/schedule-page/ScheduleEditor.jsx b/front/src/routes/integration/all/thermostat/schedule-page/ScheduleEditor.jsx new file mode 100644 index 0000000000..822ad59e42 --- /dev/null +++ b/front/src/routes/integration/all/thermostat/schedule-page/ScheduleEditor.jsx @@ -0,0 +1,627 @@ +import { Component } from 'preact'; +import { Text } from 'preact-i18n'; +import cx from 'classnames'; +import style from './style.css'; +import PRESET_COLORS from '../../../../../utils/thermostatPresetColors'; +// The slot algebra is shared with the server rather than reimplemented here: +// the editor and the regulation loop must agree on what a slot list means. +import { + applySlotToDay, + mergeIntoSlots, + timeToMinutes, + minutesToTime, + DAY_MINUTES +} from '../../../../../../../server/utils/thermostatSchedule'; + +const DAYS = [0, 1, 2, 3, 4, 5, 6]; +const PRESETS = ['off', 'frost', 'away', 'eco', 'night', 'comfort']; +const FIXED_MARKERS = [6 * 60, 12 * 60, 18 * 60]; + +function formatLabel(minutes) { + const h = Math.floor(minutes / 60) % 24; + const m = minutes % 60; + return m === 0 ? `${h}h` : `${h}h${String(m).padStart(2, '0')}`; +} + +function ensureKeys(slots) { + return slots.map((s, i) => (s.key ? s : { ...s, key: Date.now() + i + Math.random() })); +} + +class ScheduleEditor extends Component { + constructor(props) { + super(props); + this.state = { + name: props.schedule ? props.schedule.name : '', + slots: ensureKeys(props.schedule ? props.schedule.slots : []), + saving: false, + error: null, + selectedDay: null, + lastScheduleSelector: props.schedule ? props.schedule.selector : null, + copySourceDay: null, + copyTargetDays: [], + newSlotForms: {}, // { [day]: { start_time, end_time, preset } } + editForms: {} // { [key]: { start_time, end_time, preset, day_of_week } } + }; + } + + static getDerivedStateFromProps(props, state) { + const incomingSelector = props.schedule ? props.schedule.selector : null; + if (incomingSelector !== state.lastScheduleSelector) { + return { + name: props.schedule ? props.schedule.name : '', + slots: ensureKeys(props.schedule ? props.schedule.slots : []), + error: null, + selectedDay: null, + lastScheduleSelector: incomingSelector, + newSlotForms: {}, + editForms: {} + }; + } + return null; + } + + updateName = e => this.setState({ name: e.target.value }); + + selectDay = day => { + this.setState(prev => ({ selectedDay: prev.selectedDay === day ? null : day })); + }; + + // ── New slot ────────────────────────────────────────────────────────────── + + openNewSlotForm = dayOfWeek => { + const daySlots = this.state.slots + .filter(s => s.day_of_week === dayOfWeek) + .sort((a, b) => timeToMinutes(a.start_time) - timeToMinutes(b.start_time)); + + // Default: fill the first uncovered gap, or full day if no slots + let startMins = 0; + let endMins = 0; // 00:00 = full day (midnight) + if (daySlots.length > 0) { + startMins = timeToMinutes(daySlots[daySlots.length - 1].end_time) || DAY_MINUTES; + startMins = Math.min(startMins, DAY_MINUTES - 60); + endMins = Math.min(startMins + 120, DAY_MINUTES) % DAY_MINUTES; + } + + this.setState(prev => ({ + newSlotForms: { + ...prev.newSlotForms, + [dayOfWeek]: { + start_time: minutesToTime(startMins), + end_time: minutesToTime(endMins), + preset: 'comfort' + } + } + })); + }; + + closeNewSlotForm = dayOfWeek => { + this.setState(prev => { + const forms = { ...prev.newSlotForms }; + delete forms[dayOfWeek]; + return { newSlotForms: forms }; + }); + }; + + updateNewSlotForm = (dayOfWeek, field, value) => { + this.setState(prev => ({ + newSlotForms: { + ...prev.newSlotForms, + [dayOfWeek]: { ...prev.newSlotForms[dayOfWeek], [field]: value } + } + })); + }; + + confirmNewSlot = dayOfWeek => { + const form = this.state.newSlotForms[dayOfWeek]; + if (!form) return; + + const newStart = timeToMinutes(form.start_time); + let newEnd = timeToMinutes(form.end_time); + // If end <= start, the user wants overflow past midnight (e.g. 18h→06h) + if (newEnd <= newStart) newEnd = newEnd + DAY_MINUTES; + + const newKey = Date.now() + Math.random(); + const existingDaySlots = this.state.slots.filter(s => s.day_of_week === dayOfWeek); + + const { fixedSlots, overflowSlot } = applySlotToDay( + existingDaySlots, + dayOfWeek, + newStart, + newEnd, + form.preset, + newKey, + null + ); + const taggedFixed = fixedSlots.map(s => ({ ...s, day_of_week: dayOfWeek })); + const finalSlots = mergeIntoSlots(this.state.slots, dayOfWeek, taggedFixed, overflowSlot); + + this.setState(prev => { + const forms = { ...prev.newSlotForms }; + delete forms[dayOfWeek]; + return { slots: finalSlots, newSlotForms: forms }; + }); + }; + + // ── Edit existing slot ──────────────────────────────────────────────────── + + openEditForm = slot => { + this.setState(prev => ({ + editForms: { + ...prev.editForms, + [slot.key]: { + start_time: slot.start_time, + end_time: slot.end_time, + preset: slot.preset, + day_of_week: slot.day_of_week + } + } + })); + }; + + closeEditForm = slotKey => { + this.setState(prev => { + const forms = { ...prev.editForms }; + delete forms[slotKey]; + return { editForms: forms }; + }); + }; + + updateEditForm = (slotKey, field, value) => { + this.setState(prev => ({ + editForms: { + ...prev.editForms, + [slotKey]: { ...prev.editForms[slotKey], [field]: value } + } + })); + }; + + confirmEdit = slotKey => { + const form = this.state.editForms[slotKey]; + if (!form) return; + + const { day_of_week: dayOfWeek } = form; + const newStart = timeToMinutes(form.start_time); + let newEnd = timeToMinutes(form.end_time); + // If end <= start, the user wants overflow past midnight (e.g. 18h→06h) + if (newEnd <= newStart) newEnd = newEnd + DAY_MINUTES; + + const existingDaySlots = this.state.slots.filter(s => s.day_of_week === dayOfWeek); + + const { fixedSlots, overflowSlot } = applySlotToDay( + existingDaySlots, + dayOfWeek, + newStart, + newEnd, + form.preset, + slotKey, + slotKey + ); + const taggedFixed = fixedSlots.map(s => ({ ...s, day_of_week: dayOfWeek })); + const finalSlots = mergeIntoSlots(this.state.slots, dayOfWeek, taggedFixed, overflowSlot); + + this.setState(prev => { + const forms = { ...prev.editForms }; + delete forms[slotKey]; + return { slots: finalSlots, editForms: forms }; + }); + }; + + // ── Remove ──────────────────────────────────────────────────────────────── + + removeSlot = slotKey => { + this.setState(prev => ({ + slots: prev.slots.filter(s => s.key !== slotKey), + editForms: (() => { + const forms = { ...prev.editForms }; + delete forms[slotKey]; + return forms; + })() + })); + }; + + // ── Copy ────────────────────────────────────────────────────────────────── + + openCopyPicker = dayOfWeek => this.setState({ copySourceDay: dayOfWeek, copyTargetDays: [] }); + closeCopyPicker = () => this.setState({ copySourceDay: null, copyTargetDays: [] }); + + toggleCopyTarget = day => { + this.setState(prev => { + const set = new Set(prev.copyTargetDays || []); + if (set.has(day)) { + set.delete(day); + } else { + set.add(day); + } + return { copyTargetDays: Array.from(set) }; + }); + }; + + applyCopy = () => { + const { copySourceDay, copyTargetDays, slots } = this.state; + if (!copyTargetDays || copyTargetDays.length === 0) { + this.closeCopyPicker(); + return; + } + const daySlots = slots.filter(s => s.day_of_week === copySourceDay); + const otherSlots = slots.filter(s => !copyTargetDays.includes(s.day_of_week)); + const copies = []; + copyTargetDays.forEach(d => { + daySlots.forEach(s => copies.push({ ...s, day_of_week: d, key: Date.now() + d * 100 + Math.random() })); + }); + this.setState({ slots: [...otherSlots, ...copies], copySourceDay: null, copyTargetDays: [] }); + }; + + // ── Validation ──────────────────────────────────────────────────────────── + + validateSchedule = () => { + const { slots } = this.state; + const gapDays = []; + DAYS.forEach(day => { + const daySlots = slots + .filter(s => s.day_of_week === day) + .map(s => ({ + start: timeToMinutes(s.start_time), + end: timeToMinutes(s.end_time) || DAY_MINUTES + })) + .sort((a, b) => a.start - b.start); + + if (daySlots.length === 0) { + gapDays.push(day); + return; + } + + // Check coverage from 0 to DAY_MINUTES + let covered = 0; + for (const s of daySlots) { + if (s.start > covered) { + gapDays.push(day); + return; + } + covered = Math.max(covered, s.end); + } + if (covered < DAY_MINUTES) gapDays.push(day); + }); + return gapDays; + }; + + // ── Save ────────────────────────────────────────────────────────────────── + + save = async () => { + const { name, slots } = this.state; + if (!name.trim()) return; + + const gapDays = this.validateSchedule(); + if (gapDays.length > 0) { + this.setState({ error: { type: 'gaps', days: gapDays } }); + return; + } + + this.setState({ saving: true, error: null }); + const scheduleData = { + name: name.trim(), + // key is a render-only handle, and id/schedule_id belong to the row being + // replaced: neither is part of what a slot means. + slots: slots.map(({ key, id, schedule_id, ...rest }) => rest) + }; + try { + const { schedule, httpClient, onSaved } = this.props; + // A duplicate arrives as a schedule object with no selector: it is a + // creation, so gating on the object alone would PATCH /schedule/null. + if (schedule && schedule.selector) { + await httpClient.patch(`/api/v1/service/thermostat/schedule/${schedule.selector}`, scheduleData); + } else { + await httpClient.post('/api/v1/service/thermostat/schedule', scheduleData); + } + if (onSaved) onSaved(); + } catch (e) { + const msg = (e && e.response && e.response.data && e.response.data.message) || true; + this.setState({ saving: false, error: msg }); + } + }; + + // ── Render helpers ──────────────────────────────────────────────────────── + + renderTimeBar(daySlots) { + const sorted = daySlots.slice().sort((a, b) => timeToMinutes(a.start_time) - timeToMinutes(b.start_time)); + const segments = []; + sorted.forEach(slot => { + const start = timeToMinutes(slot.start_time); + const end = Math.min(timeToMinutes(slot.end_time) || DAY_MINUTES, DAY_MINUTES); + if (end <= start) return; + segments.push({ start, end, preset: slot.preset }); + }); + + const allPoints = Array.from(new Set([0, ...segments.flatMap(s => [s.start, s.end]), DAY_MINUTES])).sort( + (a, b) => a - b + ); + + const barParts = []; + for (let i = 0; i < allPoints.length - 1; i++) { + const from = allPoints[i]; + const to = allPoints[i + 1]; + const widthPct = ((to - from) / DAY_MINUTES) * 100; + const seg = segments.find(s => s.start <= from && s.end >= to); + const color = seg ? PRESET_COLORS[seg.preset] || '#ddd' : '#e9ecef'; + barParts.push({ from, to, widthPct, color }); + } + + return ( +
+
+ {barParts.map(({ from, to, widthPct, color }) => ( +
+ ))} +
+
+ {FIXED_MARKERS.map(m => ( +
+ {formatLabel(m)} +
+ ))} +
+
+ ); + } + + renderSlotForm(formData, onFieldChange, onConfirm, onCancel, onRemove, dictionary, isEdit) { + return ( +
+
+ onFieldChange('start_time', e.target.value)} + onChange={e => onFieldChange('start_time', e.target.value)} + /> + + onFieldChange('end_time', e.target.value)} + onChange={e => onFieldChange('end_time', e.target.value)} + /> + + + + {onRemove && ( + + )} +
+ ); + } + + render( + { onCancel, intl }, + { name, slots, saving, error, selectedDay, copySourceDay, copyTargetDays, newSlotForms, editForms } + ) { + const dictionary = + intl && intl.dictionary && intl.dictionary.integration && intl.dictionary.integration.thermostat + ? intl.dictionary.integration.thermostat.schedule + : {}; + + return ( +
+
+

+ {this.props.schedule ? ( + + ) : ( + + )} +

+
+
+ {error && ( +
+ {error && error.type === 'gaps' ? ( + + {' '} + {error.days.map(d => ( + + + + ))} + + ) : typeof error === 'string' ? ( + error + ) : ( + + )} +
+ )} + +
+ + +
+ +
+ {DAYS.map(day => { + const daySlots = slots + .filter(s => s.day_of_week === day) + .sort((a, b) => timeToMinutes(a.start_time) - timeToMinutes(b.start_time)); + const isOpen = selectedDay === day; + const newForm = newSlotForms[day]; + + return ( +
+
this.selectDay(day)}> +
+ + + + +
+ {this.renderTimeBar(daySlots)} +
+ + {isOpen && ( +
+ {daySlots.length === 0 && !newForm && ( +

+ +

+ )} + + {daySlots.map((slot, idx) => { + const editForm = editForms[slot.key]; + if (editForm) { + return ( +
+ {this.renderSlotForm( + editForm, + (field, value) => this.updateEditForm(slot.key, field, value), + () => this.confirmEdit(slot.key), + () => this.closeEditForm(slot.key), + () => this.removeSlot(slot.key), + dictionary, + true + )} +
+ ); + } + return ( +
this.openEditForm(slot)} + role="button" + tabIndex={0} + > +
+ {slot.start_time} + + {slot.end_time} + + {(dictionary.presets && dictionary.presets[slot.preset]) || slot.preset} + + +
+ ); + })} + + {newForm && + this.renderSlotForm( + newForm, + (field, value) => this.updateNewSlotForm(day, field, value), + () => this.confirmNewSlot(day), + () => this.closeNewSlotForm(day), + null, + dictionary, + false + )} + +
+ {!newForm && ( + + )} + {copySourceDay !== day && ( + + )} + + {copySourceDay === day && ( +
+ + + + {DAYS.filter(d => d !== day).map(d => ( + + ))} + + +
+ )} +
+
+ )} +
+ ); + })} +
+ +
+ + +
+
+
+ ); + } +} + +export default ScheduleEditor; diff --git a/front/src/routes/integration/all/thermostat/schedule-page/SchedulePage.jsx b/front/src/routes/integration/all/thermostat/schedule-page/SchedulePage.jsx new file mode 100644 index 0000000000..929cd0cd3f --- /dev/null +++ b/front/src/routes/integration/all/thermostat/schedule-page/SchedulePage.jsx @@ -0,0 +1,172 @@ +import { Component } from 'preact'; +import { Text } from 'preact-i18n'; +import get from 'get-value'; +import cx from 'classnames'; +import ThermostatPage from '../ThermostatPage'; +import ScheduleEditor from './ScheduleEditor'; +import style from './style.css'; +import withIntlAsProp from '../../../../../utils/withIntlAsProp'; + +class SchedulePageComponent extends Component { + state = { + showEditor: false, + editingSchedule: null, + confirmDeleteSelector: null + }; + + componentDidMount() { + this.props.getSchedules(); + } + + startCreate = () => { + this.setState({ showEditor: true, editingSchedule: null }); + }; + + startEdit = schedule => { + this.setState({ showEditor: true, editingSchedule: schedule }); + }; + + startDuplicate = schedule => { + // The suffix is translated: a hardcoded French one would show up for every + // English and German user too. + const copySuffix = get(this.props.intl.dictionary, 'integration.thermostat.schedule.duplicateSuffix', { + default: '(copy)' + }); + const duplicate = { + ...schedule, + // A duplicate is a creation, not an edit of the source schedule: dropping + // the id as well as the selector keeps the editor from PATCHing the original. + id: undefined, + selector: null, + name: `${schedule.name} ${copySuffix}`, + slots: schedule.slots ? schedule.slots.map(({ id, thermostat_schedule_id, ...rest }) => ({ ...rest })) : [] + }; + this.setState({ showEditor: true, editingSchedule: duplicate }); + }; + + cancelEditor = () => { + this.setState({ showEditor: false, editingSchedule: null }); + }; + + handleSaved = () => { + this.setState({ showEditor: false, editingSchedule: null }); + this.props.getSchedules(); + }; + + askDelete = selector => { + this.setState({ confirmDeleteSelector: selector }); + }; + + cancelDelete = () => { + this.setState({ confirmDeleteSelector: null }); + }; + + handleDelete = async selector => { + await this.props.deleteSchedule(selector); + this.setState({ confirmDeleteSelector: null }); + }; + + render(props, { showEditor, editingSchedule, confirmDeleteSelector }) { + const { thermostatSchedules, getSchedulesStatus, deleteScheduleStatus } = props; + + const loading = getSchedulesStatus === 'getting'; + const deleting = deleteScheduleStatus === 'getting'; + + return ( + + {showEditor ? ( + + ) : ( +
+
+

+ +

+
+ +
+
+
+ {loading && ( +
+
+
+ )} + + {!loading && (!thermostatSchedules || thermostatSchedules.length === 0) && ( +
+ +

+ +

+
+ )} + + {!loading && + thermostatSchedules && + thermostatSchedules.map(schedule => ( +
+
+

{schedule.name}

+
+ + + {confirmDeleteSelector === schedule.selector ? ( + + + + + + ) : ( + + )} +
+
+
+ ))} +
+
+ )} + + ); + } +} + +export default withIntlAsProp(SchedulePageComponent); diff --git a/front/src/routes/integration/all/thermostat/schedule-page/actions.js b/front/src/routes/integration/all/thermostat/schedule-page/actions.js new file mode 100644 index 0000000000..c2e8dbb042 --- /dev/null +++ b/front/src/routes/integration/all/thermostat/schedule-page/actions.js @@ -0,0 +1,59 @@ +import { RequestStatus } from '../../../../../utils/consts'; + +function createActions(store) { + const actions = { + async getSchedules(state) { + store.setState({ getSchedulesStatus: RequestStatus.Getting }); + try { + const schedules = await state.httpClient.get('/api/v1/service/thermostat/schedule'); + store.setState({ thermostatSchedules: schedules, getSchedulesStatus: RequestStatus.Success }); + } catch (e) { + store.setState({ getSchedulesStatus: RequestStatus.Error }); + } + }, + + async createSchedule(state, scheduleData) { + store.setState({ saveScheduleStatus: RequestStatus.Getting }); + try { + const created = await state.httpClient.post('/api/v1/service/thermostat/schedule', scheduleData); + const schedules = (state.thermostatSchedules || []).concat(created); + store.setState({ thermostatSchedules: schedules, saveScheduleStatus: RequestStatus.Success }); + return created; + } catch (e) { + store.setState({ saveScheduleStatus: RequestStatus.Error }); + return null; + } + }, + + async updateSchedule(state, selector, scheduleData) { + store.setState({ saveScheduleStatus: RequestStatus.Getting }); + try { + const updated = await state.httpClient.patch(`/api/v1/service/thermostat/schedule/${selector}`, scheduleData); + const schedules = (state.thermostatSchedules || []).map(s => (s.selector === selector ? updated : s)); + store.setState({ thermostatSchedules: schedules, saveScheduleStatus: RequestStatus.Success }); + return updated; + } catch (e) { + store.setState({ saveScheduleStatus: RequestStatus.Error }); + return null; + } + }, + + async deleteSchedule(state, selector) { + store.setState({ deleteScheduleStatus: RequestStatus.Getting }); + try { + await state.httpClient.delete(`/api/v1/service/thermostat/schedule/${selector}`); + const schedules = (state.thermostatSchedules || []).filter(s => s.selector !== selector); + store.setState({ thermostatSchedules: schedules, deleteScheduleStatus: RequestStatus.Success }); + } catch (e) { + store.setState({ deleteScheduleStatus: RequestStatus.Error }); + } + }, + + updateScheduleField(state, field, value) { + store.setState({ [field]: value }); + } + }; + return actions; +} + +export default createActions; diff --git a/front/src/routes/integration/all/thermostat/schedule-page/index.js b/front/src/routes/integration/all/thermostat/schedule-page/index.js new file mode 100644 index 0000000000..301d9fda45 --- /dev/null +++ b/front/src/routes/integration/all/thermostat/schedule-page/index.js @@ -0,0 +1,8 @@ +import { connect } from 'unistore/preact'; +import actions from './actions'; +import SchedulePage from './SchedulePage'; + +export default connect( + 'httpClient,thermostatSchedules,getSchedulesStatus,saveScheduleStatus,deleteScheduleStatus', + actions +)(SchedulePage); diff --git a/front/src/routes/integration/all/thermostat/schedule-page/style.css b/front/src/routes/integration/all/thermostat/schedule-page/style.css new file mode 100644 index 0000000000..98f60b267c --- /dev/null +++ b/front/src/routes/integration/all/thermostat/schedule-page/style.css @@ -0,0 +1,316 @@ +/* ─── Schedule Editor : vertical day list ─── */ + +.dayList { + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid #e9ecef; + border-radius: 6px; + overflow: hidden; + margin-bottom: 20px; +} + +.dayRow { + border-bottom: 1px solid #e9ecef; +} + +.dayRow:last-child { + border-bottom: none; +} + +.dayRowOpen { + background: rgba(0, 0, 0, 0.02); +} + +.dayClickZone { + cursor: pointer; + user-select: none; +} + +.dayClickZone:hover { + background: rgba(0, 0, 0, 0.04); +} + +.dayRowHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px 6px 16px; +} + +.dayLabel { + font-weight: 600; + font-size: 0.88rem; + text-transform: capitalize; + letter-spacing: 0.01em; +} + +.dayChevron { + color: #6c757d; + font-size: 0.85rem; +} + +/* ─── 24h time bar ─── */ + +.timeBarWrapper { + padding: 2px 16px 12px 16px; +} + +.timeBar { + display: flex; + height: 14px; + border-radius: 4px; + overflow: hidden; + background: transparent; + outline: 1px solid rgba(0, 0, 0, 0.06); + outline-offset: -1px; +} + +.timeBarSegment { + height: 100%; + width: var(--seg-width); + background: var(--seg-color); + flex-shrink: 0; +} + +:global(.dark-mode) .timeBarWrapper { + filter: invert(100%) hue-rotate(180deg); +} + +.timeBarMarkers { + position: relative; + height: 18px; + margin-top: 2px; +} + +.timeMarker { + position: absolute; + left: var(--marker-left); + transform: translateX(-50%); + font-size: 0.68rem; + color: #868e96; + white-space: nowrap; + line-height: 18px; +} + +.timeMarkerFixed { + color: #adb5bd; + font-size: 0.65rem; + opacity: 0.8; +} + +/* ─── Day edit panel ─── */ + +.dayPanel { + padding: 10px 16px 14px 16px; + border-top: 1px dashed #dee2e6; +} + +.noSlotsText { + font-size: 0.85rem; +} + +.slotEditorRow { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + padding: 6px 8px; + border-radius: 6px; + cursor: pointer; + border: 1px solid transparent; + transition: background 0.1s, border-color 0.1s; +} + +.slotEditorRow:hover { + background: rgba(0, 0, 0, 0.04); + border-color: #dee2e6; +} + +.slotTimeDisplay { + font-size: 0.88rem; + font-variant-numeric: tabular-nums; + color: #212529; + min-width: 42px; +} + +.slotPresetLabel { + flex: 1; + font-size: 0.85rem; + color: #495057; +} + +.slotEditIcon { + color: #adb5bd; + font-size: 0.8rem; + margin-left: auto; +} + +.newSlotForm { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; + padding: 6px 8px; + background: rgba(32, 107, 196, 0.07); + border: 1px dashed #74c0fc; + border-radius: 6px; +} + +.editSlotForm { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; + padding: 6px 8px; + background: rgba(240, 173, 78, 0.08); + border: 1px dashed #f59f00; + border-radius: 6px; +} + +.slotColorDot { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; + background: var(--dot-color, #adb5bd); +} + +:global(.dark-mode) .slotColorDot { + filter: invert(100%) hue-rotate(180deg); +} + + +.slotTimeInput { + width: 90px !important; + min-width: 0; + flex-shrink: 0; +} + +.slotArrow { + color: #6c757d; + font-size: 0.85rem; + flex-shrink: 0; +} + +.slotPresetSelect { + flex: 1; + min-width: 80px; +} + +.dayPanelActions { + display: flex; + gap: 8px; + margin-top: 10px; +} + +/* ─── Copy to days picker ─── */ + +.copyPicker { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-top: 8px; + padding: 8px 10px; + border: 1px solid rgba(0,40,100,0.12); + border-radius: 4px; + font-size: 0.82rem; +} + +.copyPickerLabel { + font-weight: 600; + margin-right: 4px; +} + +.copyPickerDay { + display: flex; + align-items: center; + gap: 3px; + cursor: pointer; + font-weight: 500; + margin: 0; +} + +/* ─── Save row ─── */ + +.saveRow { + display: flex; + gap: 8px; + margin-top: 4px; +} + +/* ─── Schedule list cards ─── */ + +.scheduleCard { + border: 1px solid rgba(0, 40, 100, 0.12); + border-radius: 3px; + margin-bottom: 12px; + background: #fff; + box-shadow: 0 1px 2px 0 rgba(0,0,0,.05); +} + +.scheduleCardHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid rgba(0, 40, 100, 0.06); +} + +.scheduleCardTitle { + font-weight: 600; + font-size: 1rem; + margin: 0; + color: #495057; +} + +.scheduleCardActions { + display: flex; + gap: 8px; +} + +/* ─── Summary mini-bars in schedule list ─── */ + +.scheduleSummaryBars { + display: flex; + flex-direction: column; + gap: 4px; +} + +.summaryDayRow { + display: flex; + align-items: center; + gap: 8px; +} + +.summaryDayName { + font-size: 0.75rem; + font-weight: 600; + color: #6c757d; + text-transform: uppercase; + width: 28px; + flex-shrink: 0; +} + +.summaryBar { + flex: 1; + height: 8px; + border-radius: 3px; + background: #e9ecef; + display: flex; + overflow: hidden; +} + +.summaryBarSegment { + height: 100%; + width: var(--seg-width); + background: var(--seg-color); + flex-shrink: 0; +} + +.emptyIcon { + font-size: 2rem; + display: block; + margin-bottom: 8px; +} From 0b3ad671826183da6d9f342a812c13b017d54f29 Mon Sep 17 00:00:00 2001 From: William Deren Date: Sun, 23 Aug 2026 12:50:59 +0200 Subject: [PATCH 07/29] feat(thermostat): add the English, French and German translations Co-Authored-By: Claude Opus 5 --- front/src/config/i18n/de.json | 185 +++++++++++++++++++++++++++++++++- front/src/config/i18n/en.json | 185 +++++++++++++++++++++++++++++++++- front/src/config/i18n/fr.json | 185 +++++++++++++++++++++++++++++++++- 3 files changed, 549 insertions(+), 6 deletions(-) diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index c6fdc53494..ba31ceeba3 100644 --- a/front/src/config/i18n/de.json +++ b/front/src/config/i18n/de.json @@ -386,7 +386,8 @@ "sun": "Sonne", "chips": "Status-Chips", "actions": "Schnellaktionen", - "house-view": "Hausansicht" + "house-view": "Hausansicht", + "thermostat": "Thermostat" }, "boxes": { "column": "Spalte {{index}}", @@ -713,6 +714,34 @@ "music": { "selectDeviceLabel": "Wähle das zu steuernde Gerät aus" }, + "thermostat": { + "defaultTitle": "Thermostat", + "editNameLabel": "Widget-Name (optional)", + "editNamePlaceholder": "z.B. Wohnzimmer Thermostat", + "thermostatFeatureLabel": "Thermostat-Funktion (Sollwert)", + "thermostatFeatureHelp": "Wähle die Funktion, die die Zieltemperatur steuert.", + "selectPlaceholder": "Auswählen...", + "inactive": "Inaktiv", + "current": "Aktuell", + "modeHeating": "Heizmodus", + "modeCooling": "Kühlmodus", + "modeOff": "Aus-Modus", + "noConfig": "Bitte konfiguriere das Widget über den Bearbeiten-Button.", + "manualMode": "Manueller Modus", + "scheduleLabel": "Zeitplan:", + "scheduleUntil": "bis", + "manualUntil": "bis", + "cancelManual": "Manuellen Modus beenden", + "error": "Fehler beim Laden der Thermostat-Daten.", + "preset": { + "off": "Aus", + "frost": "Frostschutz", + "away": "Abwesend", + "comfort": "Komfort", + "eco": "Eco", + "night": "Nacht" + } + }, "energyConsumption": { "editName": "Widget-Name (optional)", "editNamePlaceholder": "Name auf dem Dashboard angezeigt", @@ -3248,7 +3277,159 @@ "backToIntegrations": "Zurück zu den Integrationen", "menuScrollLeft": "Menü nach links scrollen", "menuScrollRight": "Menü nach rechts scrollen", - "deprecationWarning": "Diese native Integration wird bald zugunsten einer gleichwertigen externen Community-Integration eingestellt. Beide Versionen werden während der Übergangszeit im Katalog nebeneinander bestehen — du kannst diese vorerst weiter verwenden." + "deprecationWarning": "Diese native Integration wird bald zugunsten einer gleichwertigen externen Community-Integration eingestellt. Beide Versionen werden während der Übergangszeit im Katalog nebeneinander bestehen — du kannst diese vorerst weiter verwenden.", + "thermostat": { + "title": "Thermostat", + "description": "Erstelle und verwalte deine virtuellen Thermostate", + "deviceTab": "Meine Thermostate", + "scheduleTab": "Zeitpläne", + "documentationTab": "Dokumentation", + "device": { + "title": "Meine Thermostate", + "newButton": "Neu", + "noNameLabel": "Ohne Namen", + "noDevices": "Noch kein Thermostat erstellt. Klicke auf \"Neu\", um zu beginnen.", + "nameLabel": "Name", + "roomLabel": "Raum", + "activeScheduleLabel": "Aktiver Zeitplan", + "saveButton": "Speichern", + "editButton": "Bearbeiten", + "deleteButton": "Löschen", + "saveError": "Beim Speichern ist ein Fehler aufgetreten.", + "deleteError": "Beim Löschen ist ein Fehler aufgetreten." + }, + "edit": { + "titleNew": "Neues Thermostat", + "titleEdit": "Thermostat bearbeiten", + "nameLabel": "Name", + "namePlaceholder": "z.B. Wohnzimmer Thermostat", + "roomLabel": "Raum", + "modeLabel": "Modus", + "mode": { + "heating": "Heizen", + "cooling": "Kühlen" + }, + "controlTypeLabel": "Regelungsart", + "hysteresisExplain": { + "heating": "Das Thermostat schaltet die Heizung ein, wenn die Temperatur unter den Sollwert minus die Einschaltschwelle fällt, und aus, wenn sie über den Sollwert plus die Ausschaltschwelle steigt. Einfach und robust.", + "cooling": "Das Thermostat schaltet die Kühlung ein, wenn die Temperatur über den Sollwert plus die Einschaltschwelle steigt, und aus, wenn sie unter den Sollwert minus die Ausschaltschwelle fällt. Einfach und robust." + }, + "tpiExplain": "Berechnet ein ON/OFF-Verhältnis über einen festen Zyklus anhand der Abweichung zwischen aktueller Temperatur und Sollwert. Je größer die Abweichung, desto länger heizt das System innerhalb des Zyklus. Empfohlen für Fußbodenheizungen oder Systeme mit hoher Trägheit.", + "controlType": { + "hysteresis": "Hysterese", + "tpi": "TPI (Time Proportional Integral)" + }, + "tempUnitLabel": "Einheit", + "celsius": "Celsius (°C)", + "fahrenheit": "Fahrenheit (°F)", + "minTempLabel": "Min. Temperatur", + "minTempPlaceholder": "z.B. 5", + "maxTempLabel": "Max. Temperatur", + "maxTempPlaceholder": "z.B. 35", + "temperatureFeatureLabel": "Temperatursensor", + "temperatureFeatureHelp": "Wähle den Sensor, der die Umgebungstemperatur misst.", + "humidityFeatureLabel": "Feuchtigkeitssensor", + "humidityFeatureHelp": "Optional. Wird im Thermostat-Widget angezeigt.", + "switchFeatureLabel": "Schalter (Aktor)", + "switchFeatureHelp": { + "heating": "Der Schalter, der zum Heizen des Raums ein- und ausgeschaltet wird.", + "cooling": "Der Schalter, der zum Kühlen des Raums ein- und ausgeschaltet wird." + }, + "windowFeatureLabel": "Fensterkontakt", + "windowFeatureHelp": { + "heating": "Optional. Wenn ein Fenster geöffnet ist, wird die Heizung automatisch abgeschaltet.", + "cooling": "Optional. Wenn ein Fenster geöffnet ist, wird die Kühlung automatisch abgeschaltet." + }, + "hysteresisStartLabel": "Einschaltschwelle", + "hysteresisStartHelp": { + "heating": "Die Heizung schaltet ein, wenn die Temperatur unter (Sollwert − Einschaltschwelle) liegt. Z. B. Sollwert 21 °C, Schwelle 0,5 °C → schaltet unter 20,5 °C ein.", + "cooling": "Die Kühlung schaltet ein, wenn die Temperatur über (Sollwert + Einschaltschwelle) liegt. Z. B. Sollwert 24 °C, Schwelle 0,5 °C → schaltet über 24,5 °C ein." + }, + "hysteresisStopLabel": "Ausschaltschwelle", + "hysteresisStopHelp": { + "heating": "Die Heizung schaltet aus, wenn die Temperatur über (Sollwert + Ausschaltschwelle) steigt. Z. B. Sollwert 21 °C, Schwelle 0,5 °C → schaltet über 21,5 °C aus.", + "cooling": "Die Kühlung schaltet aus, wenn die Temperatur unter (Sollwert − Ausschaltschwelle) fällt. Z. B. Sollwert 24 °C, Schwelle 0,5 °C → schaltet unter 23,5 °C aus." + }, + "tpiCycleTimeLabel": "TPI-Zykluszeit", + "tpiCycleTimeHelp": "Gesamtdauer eines ON/OFF-Zyklus in Minuten. Z.B. 30 Min. → bei einem Bedarf von 50% heizt das System 15 Min. und pausiert 15 Min.", + "tpiProportionalBandLabel": "TPI-Proportionalband", + "tpiProportionalBandHelp": "Temperaturabweichung (in °C), die 100% Heizleistung entspricht. Z.B. Band = 2°C, aktuelle Abweichung = 1°C → 50% des Zyklus ON.", + "presetsLabel": "Temperatur-Voreinstellungen", + "presetColNameLabel": "Voreinstellung", + "presetColTempLabel": "Sollwert", + "preset": { + "off": "Aus", + "frost": "Frostschutz", + "away": "Abwesend", + "eco": "Eco", + "night": "Nacht", + "comfort": "Komfort" + }, + "saveButton": "Speichern", + "cancelButton": "Abbrechen", + "saveError": "Beim Speichern ist ein Fehler aufgetreten.", + "activeScheduleLabel": "Aktiver Zeitplan", + "activeScheduleHelp": "Wähle den Zeitplan, der automatisch auf dieses Thermostat angewendet wird.", + "noActiveSchedule": "Kein Zeitplan (manuelle Steuerung)", + "manualDurationLabel": "Dauer des manuellen Modus", + "manualDurationUnit": "Min.", + "manualDurationHelp": "Dauer in Minuten, bis nach einer manuellen Aktion automatisch zum Zeitplan zurückgekehrt wird (Standard: 30 Min.)." + }, + "schedule": { + "title": "Zeitpläne", + "newButton": "Neuer Zeitplan", + "noSchedules": "Noch kein Zeitplan erstellt. Klicke auf \"Neuer Zeitplan\", um zu beginnen.", + "nameLabel": "Name des Zeitplans", + "namePlaceholder": "z.B. Arbeitswoche", + "saveButton": "Speichern", + "cancelButton": "Abbrechen", + "deleteButton": "Löschen", + "editButton": "Bearbeiten", + "confirmDelete": "Löschen bestätigen?", + "confirmYes": "Ja", + "confirmNo": "Nein", + "saveError": "Beim Speichern ist ein Fehler aufgetreten.", + "gapError": "An manchen Tagen gibt es nicht abgedeckte Zeitfenster (Lücken):", + "duplicateButton": "Duplizieren", + "duplicateSuffix": "(Kopie)", + "deleteError": "Beim Löschen ist ein Fehler aufgetreten.", + "days": { + "0": "Montag", + "1": "Dienstag", + "2": "Mittwoch", + "3": "Donnerstag", + "4": "Freitag", + "5": "Samstag", + "6": "Sonntag" + }, + "daysShort": { + "0": "Mo", + "1": "Di", + "2": "Mi", + "3": "Do", + "4": "Fr", + "5": "Sa", + "6": "So" + }, + "addSlot": "Zeitfenster hinzufügen", + "copyTo": "Kopieren nach...", + "copyToLabel": "Kopieren nach:", + "applyButton": "Anwenden", + "removeSlot": "Entfernen", + "startTime": "Beginn", + "endTime": "Ende", + "preset": "Voreinstellung", + "presets": { + "off": "Aus", + "frost": "Frostschutz", + "away": "Abwesend", + "eco": "Eco", + "night": "Nacht", + "comfort": "Komfort" + }, + "noSlots": "Noch keine Zeitfenster. Klicke auf \"Zeitfenster hinzufügen\", um zu beginnen." + } + } }, "editScene": { "settings": "Einstellungen", diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index 60dc5069ae..8fe261fba1 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -386,7 +386,8 @@ "sun": "Sun", "chips": "Status chips", "actions": "Quick actions", - "house-view": "House view" + "house-view": "House view", + "thermostat": "Thermostat" }, "boxes": { "column": "Column {{index}}", @@ -822,6 +823,34 @@ "editPinLabelPlaceholder": "e.g. Solar", "noImage": "Pick an illustration in the widget settings.", "error": "The image could not be loaded." + }, + "thermostat": { + "defaultTitle": "Thermostat", + "editNameLabel": "Widget Name (optional)", + "editNamePlaceholder": "e.g. Living Room Thermostat", + "thermostatFeatureLabel": "Thermostat feature (setpoint)", + "thermostatFeatureHelp": "Select the feature that controls the target temperature.", + "selectPlaceholder": "Select...", + "inactive": "Inactive", + "current": "Current", + "modeHeating": "Heating Mode", + "modeCooling": "Cooling Mode", + "modeOff": "Off Mode", + "noConfig": "Please configure the widget by clicking the edit button.", + "manualMode": "Manual mode", + "scheduleLabel": "Schedule:", + "scheduleUntil": "until", + "manualUntil": "until", + "cancelManual": "Cancel manual mode", + "error": "Error loading thermostat data.", + "preset": { + "off": "Off", + "frost": "Frost", + "away": "Away", + "comfort": "Comfort", + "eco": "Eco", + "night": "Night" + } } }, "editDashboardBackgroundLabel": "Background mood", @@ -3248,7 +3277,159 @@ "backToIntegrations": "Back to integrations", "menuScrollLeft": "Scroll menu left", "menuScrollRight": "Scroll menu right", - "deprecationWarning": "This built-in integration will soon be deprecated in favor of an equivalent community external integration. Both versions will co-exist in the catalog during the transition — you can keep using this one for now." + "deprecationWarning": "This built-in integration will soon be deprecated in favor of an equivalent community external integration. Both versions will co-exist in the catalog during the transition — you can keep using this one for now.", + "thermostat": { + "title": "Thermostat", + "description": "Create and manage your virtual thermostats", + "deviceTab": "My Thermostats", + "scheduleTab": "Schedules", + "documentationTab": "Documentation", + "device": { + "title": "My Thermostats", + "newButton": "New", + "noNameLabel": "No name", + "noDevices": "No thermostat created yet. Click \"New\" to get started.", + "nameLabel": "Name", + "roomLabel": "Room", + "activeScheduleLabel": "Active schedule", + "saveButton": "Save", + "editButton": "Edit", + "deleteButton": "Delete", + "saveError": "Error while saving.", + "deleteError": "Error while deleting." + }, + "edit": { + "titleNew": "New Thermostat", + "titleEdit": "Edit Thermostat", + "nameLabel": "Name", + "namePlaceholder": "e.g. Living Room Thermostat", + "roomLabel": "Room", + "modeLabel": "Mode", + "mode": { + "heating": "Heating", + "cooling": "Cooling" + }, + "controlTypeLabel": "Control type", + "hysteresisExplain": { + "heating": "The thermostat turns the heating on when the temperature drops below the setpoint minus the start threshold, and turns it off when it rises above the setpoint plus the stop threshold. Simple and robust.", + "cooling": "The thermostat turns the cooling on when the temperature rises above the setpoint plus the start threshold, and turns it off when it drops below the setpoint minus the stop threshold. Simple and robust." + }, + "tpiExplain": "Computes an ON/OFF ratio over a fixed cycle based on the gap between the current temperature and the setpoint. The larger the gap, the longer the heating stays on within the cycle. Recommended for underfloor heating or high-inertia systems.", + "controlType": { + "hysteresis": "Hysteresis", + "tpi": "TPI (Time Proportional Integral)" + }, + "tempUnitLabel": "Unit", + "celsius": "Celsius (°C)", + "fahrenheit": "Fahrenheit (°F)", + "minTempLabel": "Min temp", + "minTempPlaceholder": "e.g. 5", + "maxTempLabel": "Max temp", + "maxTempPlaceholder": "e.g. 35", + "temperatureFeatureLabel": "Temperature sensor", + "temperatureFeatureHelp": "Select the sensor that measures the ambient temperature.", + "humidityFeatureLabel": "Humidity sensor", + "humidityFeatureHelp": "Optional. Displayed in the thermostat widget.", + "switchFeatureLabel": "Switch (actuator)", + "switchFeatureHelp": { + "heating": "The switch that will be turned on and off to heat the room.", + "cooling": "The switch that will be turned on and off to cool the room." + }, + "windowFeatureLabel": "Window opening sensor", + "windowFeatureHelp": { + "heating": "Optional. If a window is open, the heating is automatically cut off.", + "cooling": "Optional. If a window is open, the cooling is automatically cut off." + }, + "hysteresisStartLabel": "Start threshold", + "hysteresisStartHelp": { + "heating": "The heating turns on when the temperature is below (setpoint − start threshold). E.g. setpoint 21°C, threshold 0.5°C → turns on below 20.5°C.", + "cooling": "The cooling turns on when the temperature is above (setpoint + start threshold). E.g. setpoint 24°C, threshold 0.5°C → turns on above 24.5°C." + }, + "hysteresisStopLabel": "Stop threshold", + "hysteresisStopHelp": { + "heating": "The heating turns off when the temperature rises above (setpoint + stop threshold). E.g. setpoint 21°C, threshold 0.5°C → turns off above 21.5°C.", + "cooling": "The cooling turns off when the temperature drops below (setpoint − stop threshold). E.g. setpoint 24°C, threshold 0.5°C → turns off below 23.5°C." + }, + "tpiCycleTimeLabel": "TPI cycle time", + "tpiCycleTimeHelp": "Total duration of an ON/OFF cycle in minutes. E.g. 30 min → if the demand is 50%, the heating is ON for 15 min then OFF for 15 min.", + "tpiProportionalBandLabel": "TPI proportional band", + "tpiProportionalBandHelp": "Temperature gap (in °C) corresponding to 100% heating. E.g. band = 2°C, current gap = 1°C → 50% of the cycle ON.", + "presetsLabel": "Temperature presets", + "presetColNameLabel": "Preset", + "presetColTempLabel": "Setpoint", + "preset": { + "off": "Off", + "frost": "Frost protection", + "away": "Away", + "eco": "Eco", + "night": "Night", + "comfort": "Comfort" + }, + "saveButton": "Save", + "cancelButton": "Cancel", + "saveError": "Error while saving.", + "activeScheduleLabel": "Active schedule", + "activeScheduleHelp": "Select the schedule to automatically apply to this thermostat.", + "noActiveSchedule": "No schedule (manual control)", + "manualDurationLabel": "Manual mode duration", + "manualDurationUnit": "min", + "manualDurationHelp": "Duration in minutes before automatically returning to the schedule after a manual action (default: 30 min)." + }, + "schedule": { + "title": "Schedules", + "newButton": "New schedule", + "noSchedules": "No schedule created yet. Click \"New schedule\" to get started.", + "nameLabel": "Schedule name", + "namePlaceholder": "e.g. Work week", + "saveButton": "Save", + "cancelButton": "Cancel", + "deleteButton": "Delete", + "editButton": "Edit", + "confirmDelete": "Confirm deletion?", + "confirmYes": "Yes", + "confirmNo": "No", + "saveError": "Error while saving.", + "gapError": "Some days have uncovered time slots (gaps):", + "duplicateButton": "Duplicate", + "duplicateSuffix": "(copy)", + "deleteError": "Error while deleting.", + "days": { + "0": "Monday", + "1": "Tuesday", + "2": "Wednesday", + "3": "Thursday", + "4": "Friday", + "5": "Saturday", + "6": "Sunday" + }, + "daysShort": { + "0": "Mon", + "1": "Tue", + "2": "Wed", + "3": "Thu", + "4": "Fri", + "5": "Sat", + "6": "Sun" + }, + "addSlot": "Add time slot", + "copyTo": "Copy to...", + "copyToLabel": "Copy to:", + "applyButton": "Apply", + "removeSlot": "Remove", + "startTime": "Start", + "endTime": "End", + "preset": "Preset", + "presets": { + "off": "Off", + "frost": "Frost protection", + "away": "Away", + "eco": "Eco", + "night": "Night", + "comfort": "Comfort" + }, + "noSlots": "No time slots yet. Click \"Add time slot\" to get started." + } + } }, "editScene": { "settings": "Settings", diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 0874b90eb1..5faa904100 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -386,7 +386,8 @@ "sun": "Soleil", "chips": "Pastilles d'état", "actions": "Actions rapides", - "house-view": "Vue de la maison" + "house-view": "Vue de la maison", + "thermostat": "Thermostat" }, "boxes": { "column": "Colonne {{index}}", @@ -713,6 +714,34 @@ "editNameLabel": "Nom du widget (optionnel)", "editNamePlaceholder": "Nom affiché sur le tableau de bord" }, + "thermostat": { + "defaultTitle": "Thermostat", + "editNameLabel": "Nom du widget (optionnel)", + "editNamePlaceholder": "Ex: Thermostat Salon", + "thermostatFeatureLabel": "Fonctionnalité thermostat (consigne)", + "thermostatFeatureHelp": "Sélectionnez la fonctionnalité qui contrôle la température cible.", + "selectPlaceholder": "Sélectionner...", + "inactive": "Inactif", + "current": "Actuel", + "modeHeating": "Mode Chauffage", + "modeCooling": "Mode Climatisation", + "modeOff": "Mode Arrêt", + "noConfig": "Veuillez configurer le widget en cliquant sur le bouton d'édition.", + "manualMode": "Mode manuel", + "scheduleLabel": "Planning :", + "scheduleUntil": "jusqu'à", + "manualUntil": "jusqu'à", + "cancelManual": "Annuler le mode manuel", + "error": "Erreur lors du chargement des données du thermostat.", + "preset": { + "off": "Arrêt", + "frost": "Hors-gel", + "away": "Absent", + "comfort": "Confort", + "eco": "Éco", + "night": "Nuit" + } + }, "energyConsumption": { "editName": "Nom du widget (optionnel)", "editNamePlaceholder": "Nom affiché sur le tableau de bord", @@ -3248,7 +3277,159 @@ "backToIntegrations": "Retour aux intégrations", "menuScrollLeft": "Faire défiler le menu vers la gauche", "menuScrollRight": "Faire défiler le menu vers la droite", - "deprecationWarning": "Cette intégration native sera bientôt dépréciée au profit d'une intégration externe équivalente maintenue par la communauté. Les deux versions vont co-exister dans le catalogue pendant la transition — vous pouvez continuer à utiliser celle-ci pour le moment." + "deprecationWarning": "Cette intégration native sera bientôt dépréciée au profit d'une intégration externe équivalente maintenue par la communauté. Les deux versions vont co-exister dans le catalogue pendant la transition — vous pouvez continuer à utiliser celle-ci pour le moment.", + "thermostat": { + "title": "Thermostat", + "description": "Créez et gérez vos thermostats virtuels", + "deviceTab": "Mes thermostats", + "scheduleTab": "Plannings", + "documentationTab": "Documentation", + "device": { + "title": "Mes thermostats", + "newButton": "Nouveau", + "noNameLabel": "Sans nom", + "noDevices": "Aucun thermostat créé. Cliquez sur \"Nouveau\" pour commencer.", + "nameLabel": "Nom", + "roomLabel": "Pièce", + "activeScheduleLabel": "Planning actif", + "saveButton": "Sauvegarder", + "editButton": "Éditer", + "deleteButton": "Supprimer", + "saveError": "Erreur lors de la sauvegarde.", + "deleteError": "Erreur lors de la suppression." + }, + "edit": { + "titleNew": "Nouveau thermostat", + "titleEdit": "Modifier le thermostat", + "nameLabel": "Nom", + "namePlaceholder": "Ex: Thermostat Salon", + "roomLabel": "Pièce", + "modeLabel": "Mode", + "mode": { + "heating": "Chauffage", + "cooling": "Climatisation" + }, + "controlTypeLabel": "Type de calcul", + "hysteresisExplain": { + "heating": "Le thermostat allume le chauffage lorsque la température descend sous la consigne moins le seuil de démarrage, et l'éteint lorsqu'elle remonte au-dessus de la consigne plus le seuil d'arrêt. Simple et robuste.", + "cooling": "Le thermostat allume la climatisation lorsque la température monte au-dessus de la consigne plus le seuil de démarrage, et l'éteint lorsqu'elle redescend sous la consigne moins le seuil d'arrêt. Simple et robuste." + }, + "tpiExplain": "Calcule un rapport ON/OFF sur un cycle fixe selon l'écart entre la température actuelle et la consigne. Plus l'écart est grand, plus le chauffage est actif longtemps dans le cycle. Recommandé pour les planchers chauffants ou les systèmes à inertie élevée.", + "controlType": { + "hysteresis": "Hystérésis", + "tpi": "TPI (Time Proportional Integral)" + }, + "tempUnitLabel": "Unité", + "celsius": "Celsius (°C)", + "fahrenheit": "Fahrenheit (°F)", + "minTempLabel": "Temp. min", + "minTempPlaceholder": "Ex: 5", + "maxTempLabel": "Temp. max", + "maxTempPlaceholder": "Ex: 35", + "temperatureFeatureLabel": "Capteur de température", + "temperatureFeatureHelp": "Sélectionnez le capteur qui mesure la température ambiante.", + "humidityFeatureLabel": "Capteur d'humidité", + "humidityFeatureHelp": "Optionnel. Affiché dans le widget thermostat.", + "switchFeatureLabel": "Commutateur (actionneur)", + "switchFeatureHelp": { + "heating": "Interrupteur qui sera allumé et éteint pour chauffer la pièce.", + "cooling": "Interrupteur qui sera allumé et éteint pour rafraîchir la pièce." + }, + "windowFeatureLabel": "Capteur d'ouverture de fenêtre", + "windowFeatureHelp": { + "heating": "Optionnel. Si une fenêtre est ouverte, le chauffage est automatiquement coupé.", + "cooling": "Optionnel. Si une fenêtre est ouverte, la climatisation est automatiquement coupée." + }, + "hysteresisStartLabel": "Seuil de démarrage", + "hysteresisStartHelp": { + "heating": "Le chauffage démarre lorsque la température est inférieure à (consigne − seuil de démarrage). Ex. : consigne 21 °C, seuil 0,5 °C → démarre sous 20,5 °C.", + "cooling": "La climatisation démarre lorsque la température est supérieure à (consigne + seuil de démarrage). Ex. : consigne 24 °C, seuil 0,5 °C → démarre au-dessus de 24,5 °C." + }, + "hysteresisStopLabel": "Seuil d'arrêt", + "hysteresisStopHelp": { + "heating": "Le chauffage s'arrête lorsque la température dépasse (consigne + seuil d'arrêt). Ex. : consigne 21 °C, seuil 0,5 °C → s'arrête au-dessus de 21,5 °C.", + "cooling": "La climatisation s'arrête lorsque la température descend sous (consigne − seuil d'arrêt). Ex. : consigne 24 °C, seuil 0,5 °C → s'arrête sous 23,5 °C." + }, + "tpiCycleTimeLabel": "Durée du cycle TPI", + "tpiCycleTimeHelp": "Durée totale d'un cycle ON/OFF en minutes. Ex: 30 min → si le besoin est 50%, le chauffage est ON 15 min puis OFF 15 min.", + "tpiProportionalBandLabel": "Bande proportionnelle TPI", + "tpiProportionalBandHelp": "Écart de température (en °C) correspondant à 100% de chauffe. Ex: bande = 2°C, écart actuel = 1°C → 50% du cycle en ON.", + "presetsLabel": "Presets de température", + "presetColNameLabel": "Preset", + "presetColTempLabel": "Consigne", + "preset": { + "off": "Arrêt", + "frost": "Hors-gel", + "away": "Absence", + "eco": "Éco", + "night": "Nuit", + "comfort": "Confort" + }, + "saveButton": "Enregistrer", + "cancelButton": "Annuler", + "saveError": "Erreur lors de la sauvegarde.", + "activeScheduleLabel": "Planning actif", + "activeScheduleHelp": "Sélectionnez le planning à appliquer automatiquement à ce thermostat.", + "noActiveSchedule": "Aucun planning (contrôle manuel)", + "manualDurationLabel": "Durée du mode manuel", + "manualDurationUnit": "min", + "manualDurationHelp": "Durée en minutes avant de revenir automatiquement au planning après une action manuelle (défaut : 30 min)." + }, + "schedule": { + "title": "Plannings", + "newButton": "Nouveau planning", + "noSchedules": "Aucun planning créé. Cliquez sur \"Nouveau planning\" pour commencer.", + "nameLabel": "Nom du planning", + "namePlaceholder": "Ex: Semaine de travail", + "saveButton": "Enregistrer", + "cancelButton": "Annuler", + "deleteButton": "Supprimer", + "editButton": "Éditer", + "confirmDelete": "Confirmer la suppression ?", + "confirmYes": "Oui", + "confirmNo": "Non", + "saveError": "Erreur lors de la sauvegarde.", + "gapError": "Certains jours n'ont pas de couverture complète (plages manquantes) :", + "duplicateButton": "Dupliquer", + "duplicateSuffix": "(copie)", + "deleteError": "Erreur lors de la suppression.", + "days": { + "0": "Lundi", + "1": "Mardi", + "2": "Mercredi", + "3": "Jeudi", + "4": "Vendredi", + "5": "Samedi", + "6": "Dimanche" + }, + "daysShort": { + "0": "Lun", + "1": "Mar", + "2": "Mer", + "3": "Jeu", + "4": "Ven", + "5": "Sam", + "6": "Dim" + }, + "addSlot": "Ajouter une plage", + "copyTo": "Copier vers...", + "copyToLabel": "Copier vers :", + "applyButton": "Appliquer", + "removeSlot": "Supprimer", + "startTime": "Début", + "endTime": "Fin", + "preset": "Preset", + "presets": { + "off": "Arrêt", + "frost": "Hors-gel", + "away": "Absence", + "eco": "Éco", + "night": "Nuit", + "comfort": "Confort" + }, + "noSlots": "Aucune plage horaire. Cliquez sur \"Ajouter une plage\" pour commencer." + } + } }, "editScene": { "settings": "Configuration", From bac0b9465a8d3296e327b565fb53f5e1e3d9d07f Mon Sep 17 00:00:00 2001 From: William Deren Date: Sun, 23 Aug 2026 12:50:59 +0200 Subject: [PATCH 08/29] docs(thermostat): add the living specs for the integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md requires the dashboard and device-migration specs to be updated in the same diff as a new box type and new device-referencing fields. The integration spec itself records the decisions a reader would otherwise have to reverse-engineer: why the config lives on the device rather than on the widget, why schedules are read in the Gladys timezone, why an external setValue becomes a manual override, and why the presets (off/frost/away/eco/night/comfort) are a separate vocabulary from THERMOSTAT_MODE rather than a competing spelling of it — the enum says what the machine does, a preset says which temperature to aim for, and only the latter can be put in a time slot. Co-Authored-By: Claude Opus 5 --- .../dashboard-flexible-layout-and-widgets.md | 16 +++ docs/specs/device-migration.md | 2 +- docs/specs/thermostat.md | 119 ++++++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 docs/specs/thermostat.md diff --git a/docs/specs/dashboard-flexible-layout-and-widgets.md b/docs/specs/dashboard-flexible-layout-and-widgets.md index eb9d06a9b5..6fa91e3a0f 100644 --- a/docs/specs/dashboard-flexible-layout-and-widgets.md +++ b/docs/specs/dashboard-flexible-layout-and-widgets.md @@ -191,6 +191,22 @@ The signature widget: an illustration (typically the user's house) with live dev - Assets are deleted with their dashboard — explicitly by `dashboard.destroy` (production never sets `PRAGMA foreign_keys`, so the declared FK cascade alone would not fire on SQLite). An asset orphaned by replacing a box image lives until then; a cleanup pass can come later if it ever matters. - Optional **night variant**: a second image auto-swapped by sun state — **deferred**, one additive field when it comes, no schema impact. +## E2. New box type: `thermostat` + +A circular-gauge widget for the thermostat integration (`docs/specs/thermostat.md`): current temperature and humidity, target setpoint, a preset bar and a drag-to-set dial. + +- New `DASHBOARD_BOX_TYPE.THERMOSTAT = 'thermostat'` in `server/utils/constants.js`, stretching as a *tile*. +- Box config — deliberately **one device-referencing key and nothing else**: + +| Key | Meaning | +|---|---| +| `thermostat_feature` | selector of the `thermostat` / `target-temperature` feature to display | +| `name` | optional card title override (shared with every other box type) | + +- **Why the config is this small.** Regulation settings — the sensor, the switch, the active schedule, the six preset temperatures, hysteresis, TPI cycle and band — are **device params**, never box fields. Putting them in `t_dashboard.boxes` would make a per-user dashboard document the source of truth of a control loop that turns real heaters on and off: the loop would have to read every dashboard on each tick, including the **private** dashboards of other users, and the same thermostat displayed on two dashboards with different settings would resolve non-deterministically. The widget chooses *which* thermostat to display; it never owns the regulation. +- `thermostat_feature` is a device-referencing field: it is listed in `FEATURE_STRING_FIELDS` (`server/lib/device/device.migrate.js`) and in `docs/specs/device-migration.md` B.3, so migrating the thermostat device rewrites the widget's selector. +- Values update live over the device websocket plus the `THERMOSTAT.*` messages (`PRESET_UPDATED`, `MANUAL_MODE_UPDATED`, `CONFIG_UPDATED`) the service broadcasts. + ## F. AI illustration generation through Gladys Plus Generating the `house-view` illustration is the one step that cannot be beautiful-by-default from a form alone. Gladys Plus already proxies AI calls; illustration generation follows the exact same pattern. diff --git a/docs/specs/device-migration.md b/docs/specs/device-migration.md index 53e2f7cf50..164f19a2a2 100644 --- a/docs/specs/device-migration.md +++ b/docs/specs/device-migration.md @@ -81,7 +81,7 @@ Two replacement maps are built once: `featureReplacements` (mapped source featur Fields rewritten — this list is **exhaustive and must stay in sync with the Joi schemas** of `server/models/scene.js` and `server/models/dashboard.js` (both reject unknown keys, so any new device-referencing field lands here in the same diff): - **Scene actions** (`t_scene.actions`, array of arrays, recursing into `condition.if-then-else`'s `if` / `then` / `else`): `device_feature` (feature), `device_features[]` (features), `device` (device), `devices[]` (devices), `camera` (device). - **Scene triggers** (`t_scene.triggers`, flat array): `device_feature` (feature), `device_features[]` (features), `device` (device — schema-declared legacy field, rewritten for safety). -- **Dashboard boxes** (`t_dashboard.boxes`, array of *sections* `{ columns: [[box]] }` since the flexible layout — legacy arrays of arrays are still walked): `device_feature` (feature), `device_features[]` (features), `device` (device), `camera` (device); plus the nested selector holders introduced by the wall-panel widgets — `chips[].device_feature` (feature), `pins[].device_feature` (feature), `actions[].device_feature` (feature, quick-actions box), and the **values** of `scene_status_features` (scene selector → feature selector map; keys are scene selectors and are not rewritten). Values are replaced **in place**; array length and order never change, keeping `device_feature_names` / `units` / `colors` index-aligned. +- **Dashboard boxes** (`t_dashboard.boxes`, array of *sections* `{ columns: [[box]] }` since the flexible layout — legacy arrays of arrays are still walked): `device_feature` (feature), `device_features[]` (features), `device` (device), `camera` (device); plus the nested selector holders introduced by the wall-panel widgets — `chips[].device_feature` (feature), `pins[].device_feature` (feature), `actions[].device_feature` (feature, quick-actions box), `thermostat_feature` (feature, thermostat box — the only device-referencing key that box carries, every regulation setting living on the device instead), and the **values** of `scene_status_features` (scene selector → feature selector map; keys are scene selectors and are not rewritten). Values are replaced **in place**; array length and order never change, keeping `device_feature_names` / `units` / `colors` index-aligned. Only scenes/dashboards that actually changed are saved. Rewritten scenes go through `SceneManager.addScene` so the RAM copy (`this.scenes`, the one `checkTrigger` iterates) and its scheduled triggers are replaced atomically with the DB copy — the same path as `scene.update`. Dashboards have no RAM cache. References to **unmapped** source features are intentionally left dangling (existing deletion semantics; the UI warned). diff --git a/docs/specs/thermostat.md b/docs/specs/thermostat.md new file mode 100644 index 0000000000..ad74dc8cd7 --- /dev/null +++ b/docs/specs/thermostat.md @@ -0,0 +1,119 @@ +# Thermostat: a virtual thermostat with weekly schedules + +## Context + +Gladys can already *read and command* real thermostats (Netatmo, Matter, Zigbee, Z-Wave) through the `thermostat` device feature category. What it cannot do is **be** the thermostat: turn a plain temperature sensor plus a plain switch — a relay, a smart plug, a boiler contact — into a regulated heating zone with a weekly programme. + +That is the gap this integration fills, and it is the most common French setup: an electric or hydronic heater driven by a contact, a separate sensor in the room, no branded thermostat anywhere. Today the answer is a hand-written scene per temperature threshold, with no schedule, no hysteresis and no anti-short-cycling. + +## A. Device model + +One virtual device per heating zone, created by the integration, carrying exactly **one** feature: + +| | | +|---|---| +| Category | `DEVICE_FEATURE_CATEGORIES.THERMOSTAT` | +| Type | `DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE` | +| Unit | `celsius` or `fahrenheit` | + +No new category or type is introduced: a virtual thermostat is a thermostat, and it must be indistinguishable from a Netatmo one to the rest of Gladys (scenes, MQTT, Gladys Plus, the device pages). + +The feature is resolved **by category and type**, never by `device.features[0]`: feature order is not a contract, and a later added feature (mode, operating state) would otherwise silently retarget the regulation loop. + +### A.1 Configuration lives on the device + +Everything the control loop needs is a `THERMOSTAT_*` device param: + +| Param | Meaning | +|---|---| +| `THERMOSTAT_TEMPERATURE_FEATURE` | the sensor the loop regulates on | +| `THERMOSTAT_HUMIDITY_FEATURE` | optional, displayed only | +| `THERMOSTAT_SWITCH_FEATURE` | the actuator the loop drives | +| `THERMOSTAT_WINDOW_FEATURE` | optional opening sensor, cuts the heating when open | +| `THERMOSTAT_ACTIVE_SCHEDULE` | selector of the weekly schedule to follow, empty for none | +| `THERMOSTAT_MODE` | `heating` or `cooling` | +| `THERMOSTAT_CONTROL_TYPE` | `hysteresis` or `tpi` | +| `THERMOSTAT_MIN_TEMP` / `_MAX_TEMP` | bounds of the setpoint feature and of the widget dial | +| `THERMOSTAT_TEMP_UNIT` | `C` or `F` | +| `THERMOSTAT_MANUAL_DURATION` | how long a manual override holds, in minutes | +| `THERMOSTAT_PRESET_*` | the six preset setpoints | +| `THERMOSTAT_HYSTERESIS_START` / `_STOP` | hysteresis band, in degrees of **difference** | +| `THERMOSTAT_TPI_CYCLE_TIME` / `_PROPORTIONAL_BAND` | TPI tuning | + +`createDevice` accepts only this list plus a single setpoint feature; anything else in the request body is dropped rather than persisted. Every field the edit form offers is in that list: a field the filter dropped would silently need a second store, which is exactly what this section forbids. + +The defaults for all of these live in `server/utils/thermostatConstants.js`, imported by the regulation loop, the widget and the edit form alike, so a device saved without a param is regulated exactly as the form displayed it. + +### A.2 Runtime state + +Per-thermostat *runtime* state — current preset, its non-off fallback, the manual override and its expiry — stays in `t_variable` under `THERMOSTAT__`, scoped to this **service id** rather than written globally, and removed by the service's `postDelete` hook when the device is deleted. The suffix list is shared between the write path and the cleanup, so a new suffix cannot be left behind. + +Clients read and write it through `/api/v1/service/thermostat/state/:variable_key`, which accepts the runtime suffixes only. It is deliberately *not* mounted under `.../variable/...`: the core already mounts `/api/v1/service/:service_name/variable/:variable_key`, and that generic route would shadow it. Configuration keys are rejected there — the configuration lives on the device, and there is no `THERMOSTAT_CONFIG_*` variable any more. + +**Not on the dashboard.** See `docs/specs/dashboard-flexible-layout-and-widgets.md` E2: the widget carries `thermostat_feature` and nothing else. A control loop that actuates real heaters must not read its settings from a per-user dashboard document. + +## B. Presets, and why they are not `THERMOSTAT_MODE` + +The integration exposes six presets: `off`, `frost`, `away`, `eco`, `night`, `comfort`. + +These are **not** a competing spelling of the existing `THERMOSTAT_MODE` enum (`off` / `heating` / `cooling` / `auto`) that Matter, Zigbee and Z-Wave map onto. The two answer different questions: + +- `THERMOSTAT_MODE` says **what the machine does** — is it heating, cooling, or idle. It is a property of the equipment. +- A preset says **which temperature to aim for** — 7 °C frost protection, 16 °C away, 21 °C comfort. It is a property of the schedule. + +They compose rather than compete: a thermostat in `heating` mode follows a weekly programme whose 07:00 slot is `comfort`. This is the Netatmo/Tado vocabulary, and the vocabulary French heating programmers have used for decades (*confort / éco / hors-gel*), which is what makes a weekly schedule expressible at all — "heating" is not something you can put in a time slot. + +The presets are stored as a device-scoped variable and as the `preset` column of a schedule slot; they are **not** exposed as a device feature. A scene that wants a specific temperature sets the setpoint (section D); mapping presets onto a standard feature category can be added later without changing this model. + +> Open question for maintainers: whether a future `thermostat` / `preset` feature type should exist Gladys-wide, so branded integrations with the same notion (Netatmo, Tado, Overkiz) expose it uniformly. Out of scope here. + +## C. Regulation loop + +A single `setInterval` in the service ticks every 60 s and calls `applySchedules`, which regulates every thermostat device in parallel and isolates a failing device from the others. + +**Order of decisions**, per device: + +1. **Window open** — if a window sensor is configured and reads `0`, the switch is cut and the pass stops. A `NEW_STATE` listener applies the same cut immediately, without waiting for the next tick, using device params only (no dashboard read). +2. **Manual override** — if `THERMOSTAT_*_MANUAL_MODE` is `true` and its `_MANUAL_UNTIL` has not passed, the loop regulates on the manual setpoint. On expiry it clears the flag, broadcasts `MANUAL_MODE_UPDATED` and falls through to the schedule. +3. **Target preset** — the active schedule's slot for the current day and minute; failing that, the current preset variable; failing that, nothing is regulated. +4. **Setpoint** — written to the thermostat feature only when it changed. +5. **Switch** — actuated only when its state differs from the computed one. + +### C.1 Timezone + +Schedules are wall-clock times **in the house**. `getCurrentDayAndMinutes` therefore reads the day and minute in the timezone from `SYSTEM_VARIABLE_NAMES.TIMEZONE` (default `Europe/Paris`), like scenes, DuckDB and the energy jobs do — the official Docker image runs in UTC, so relying on the process timezone would fire a 07:00 comfort slot at 08:00 or 09:00 in France. + +The helper lives in `server/utils/thermostatSchedule.js`, imported by both the service and the widget so the two agree on the active slot — the schedule editor's slot algebra (`applySlotToDay`, `mergeIntoSlots`) comes from the same module rather than a second copy. It is deliberately in `utils/` and not in the service directory: the frontend build only aliases `server/utils/*`, and a service module is free to `require('../models')`, which would break the Vite build. + +The widget passes that timezone explicitly, read once from `SYSTEM_VARIABLE_NAMES.TIMEZONE`. Letting it default to the browser's would make a phone abroad, or a laptop left on another zone, display a slot other than the one actually heating the house. + +### C.2 Hysteresis and TPI + +- **Hysteresis** (default): heat below `setpoint - hysteresis_start`, stop above `setpoint + hysteresis_stop`, hold the current state in between. Both values are temperature **differences**, so converting a thermostat to Fahrenheit scales them by 9/5 with **no** 32° offset — the absolute-temperature conversion would turn a 0.5 °C hysteresis into 32.9 °F. +- **TPI**: the switch is on for a fraction of each cycle proportional to the error within the proportional band. Heating only — a cooling compressor cannot be pulsed that way, so cooling always falls back to hysteresis. An on-time below one minute is rounded down to off: the regulation step is one minute, and a shorter pulse is both useless and hard on the relay. +- **TPI phase.** The position inside the cycle is offset by a hash of the thermostat's feature selector. Without it, every thermostat sharing a cycle time switches on at the same wall-clock minute, stacking the loads. + +## D. Scenes + +`setValue` is the path taken by `device.set-value` and by the generic device API. Persisting the value alone would not survive: the next regulation pass re-applies the scheduled preset and overwrites it within a minute, so a scene setting 21 °C would either do nothing useful or fight the loop every minute. + +An external write is therefore treated as a **manual override**, exactly like turning the dial on the widget: the setpoint is saved, the manual flag and its expiry are set, `MANUAL_MODE_UPDATED` is broadcast and a regulation pass is triggered. The setpoint holds for the device's `THERMOSTAT_MANUAL_DURATION` (30 minutes by default), then the schedule takes over again. The widget's countdown reads the same param, so what it displays is what the server enforces. + +`POST /api/v1/service/thermostat/setpoint/:feature_selector` goes through the same `setValue`, and only after checking that the named feature is a `thermostat` / `target-temperature` feature **owned by this service** — otherwise any authenticated household member could persist a value on a lock, a cover or a light just by naming its selector. + +## E. Weekly schedules + +Two tables (migration `20260823000000`): + +- `t_thermostat_schedule`: `id`, `name`, `selector`. +- `t_thermostat_schedule_slot`: `schedule_id` (FK, `ON DELETE CASCADE`), `day_of_week` (0 = Monday … 6 = Sunday), `start_time` / `end_time` in `HH:MM`, `preset`. + +Slots are validated by Joi before reaching the database (`day_of_week` 0–6, `HH:MM` pattern, preset enum) and by the model itself. An invalid slot would otherwise be stored and then silently match nothing at regulation time. + +A slot ending at `00:00` means end of day. A slot whose end is before its start crosses midnight and is matched in two halves — the start day's evening, then the following day's small hours — which is what makes a single "22:00 → 06:00 night" slot expressible. + +## F. Out of scope + +- Fil pilote heaters (`heater` / `pilot-wire-mode`): the actuator picker is `switch` / `binary` only. Additive when it comes. +- Presets as a Gladys-wide device feature type (section B). +- Multi-zone grouping, holiday mode, open-window *detection* by temperature drop (as opposed to a sensor). From 88bd71d8d3bc06f18e572e6951f7802f7ff32081 Mon Sep 17 00:00:00 2001 From: William Deren Date: Mon, 24 Aug 2026 00:19:01 +0200 Subject: [PATCH 09/29] fix(thermostat): harden the regulation loop against out-of-range input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review of #2988 found several places where a value the UI cannot produce still reaches the control loop, and one where a gesture left the device in a state nothing would clear. The TPI parameters are now clamped server-side to the bounds the edit form advertises. An HTML `min` is only a browser hint: a device saved through the API can carry a zero, and a zero band divides into Infinity (the heater stays on whenever the room is below setpoint) while a zero cycle time makes the modulo NaN (the heater never turns on at all). The setpoint route rejects the raw value before coercing it. Number('') and Number(null) are both 0, so an empty body used to be accepted as a manual hold at 0 °C. Dragging the widget dial wrote MANUAL_MODE on pointer-down but the setpoint and its expiry only on pointer-up. Unmounting mid-drag — a dashboard edit, a tab switch — left the device in manual mode with no MANUAL_UNTIL, and the loop then held the switch in whatever state it was in, indefinitely. Everything is written together on release now, so an abandoned gesture persists nothing. Schedule names are unique in the database as well. The duplicate precheck is not atomic, so two concurrent writes could both pass it; create and update translate the constraint violation into the same message the precheck raises, and the caller sees one behaviour whichever check caught it. Smaller fixes from the same review: the feature reload resets openingFeatures on failure like the other three lists, the preset inputs keep an explicitly entered 0 instead of falling back to the default, a failed schedule deletion keeps its confirmation open and shows the error string that already existed but was never rendered, and the spec says five preset setpoints rather than six — `off` has none. While fixing the deletion message: SchedulePage compared its request statuses against lowercase literals, which RequestStatus never produces, so the loading spinner and the deleting state had never once been shown. Co-Authored-By: Claude Opus 5 --- docs/specs/thermostat.md | 4 +- .../boxs/thermostat/ThermostatBox.jsx | 14 +++- .../all/thermostat/edit-page/actions.js | 19 +++-- .../thermostat/schedule-page/SchedulePage.jsx | 22 +++++- .../all/thermostat/schedule-page/actions.js | 2 + ...260823000000-create-thermostat-schedule.js | 3 + server/models/thermostat_schedule.js | 1 + .../thermostat/api/thermostat.controller.js | 10 ++- .../lib/thermostat.applySchedules.js | 32 +++++++- .../lib/thermostat.createSchedule.js | 37 ++++++---- .../lib/thermostat.updateSchedule.js | 44 ++++++----- .../api/thermostat.controller.test.js | 46 ++++++++++++ ...thermostat.applySchedules.helpers2.test.js | 22 ++++++ .../lib/thermostat.schedules.test.js | 74 +++++++++++++++++++ server/utils/thermostatConstants.js | 12 +++ 15 files changed, 294 insertions(+), 48 deletions(-) diff --git a/docs/specs/thermostat.md b/docs/specs/thermostat.md index ad74dc8cd7..73656229af 100644 --- a/docs/specs/thermostat.md +++ b/docs/specs/thermostat.md @@ -36,9 +36,9 @@ Everything the control loop needs is a `THERMOSTAT_*` device param: | `THERMOSTAT_MIN_TEMP` / `_MAX_TEMP` | bounds of the setpoint feature and of the widget dial | | `THERMOSTAT_TEMP_UNIT` | `C` or `F` | | `THERMOSTAT_MANUAL_DURATION` | how long a manual override holds, in minutes | -| `THERMOSTAT_PRESET_*` | the six preset setpoints | +| `THERMOSTAT_PRESET_*` | the five preset setpoints (`off` has no setpoint) | | `THERMOSTAT_HYSTERESIS_START` / `_STOP` | hysteresis band, in degrees of **difference** | -| `THERMOSTAT_TPI_CYCLE_TIME` / `_PROPORTIONAL_BAND` | TPI tuning | +| `THERMOSTAT_TPI_CYCLE_TIME` / `_PROPORTIONAL_BAND` | TPI tuning, clamped by the regulation loop to 5-120 min and 0.5-10 degrees | `createDevice` accepts only this list plus a single setpoint feature; anything else in the request body is dropped rather than persisted. Every field the edit form offers is in that list: a field the filter dropped would silently need a second store, which is exactly what this section forbids. diff --git a/front/src/components/boxs/thermostat/ThermostatBox.jsx b/front/src/components/boxs/thermostat/ThermostatBox.jsx index 591f46fc3c..51a5366212 100644 --- a/front/src/components/boxs/thermostat/ThermostatBox.jsx +++ b/front/src/components/boxs/thermostat/ThermostatBox.jsx @@ -661,7 +661,9 @@ class ThermostatBox extends Component { componentWillUnmount() { clearInterval(this.clockInterval); // A drag in progress keeps window-level listeners alive: unmounting - // mid-drag (dashboard edit, tab switch) would leak them. + // mid-drag (dashboard edit, tab switch) would leak them. The gesture never + // reached pointer-up, so nothing was persisted and there is nothing to + // undo — the device keeps whatever mode it had before the drag started. this.stopDrag(); if (this.expectedSetpointTimer) { clearTimeout(this.expectedSetpointTimer); @@ -733,7 +735,10 @@ class ThermostatBox extends Component { manualSetpointOverride: true }); } - this.saveManualMode(true); + // MANUAL_MODE is written on release, together with the setpoint and the + // expiry: writing it here would leave the device in manual mode with no + // MANUAL_UNTIL if the box unmounts mid-drag, and the regulation loop would + // then hold the switch in its current state indefinitely. let lastDragSetpoint = this.angleToTemp(angle); this._onMove = ev => { ev.preventDefault(); @@ -744,8 +749,9 @@ class ThermostatBox extends Component { this.setState({ setpoint: lastDragSetpoint }); } }; - this._onUp = () => { + this._onUp = async () => { this.stopDrag(); + await this.saveManualMode(true); this.sendSetpoint(lastDragSetpoint); this.saveManualSetpoint(lastDragSetpoint); // A manual setpoint only needs a timer when a schedule would otherwise @@ -763,6 +769,8 @@ class ThermostatBox extends Component { if (this._onUp) window.removeEventListener('pointerup', this._onUp); if (this._onMove) window.removeEventListener('touchmove', this._onMove); if (this._onUp) window.removeEventListener('touchend', this._onUp); + this._onMove = null; + this._onUp = null; this.setState({ isDragging: false }); }; diff --git a/front/src/routes/integration/all/thermostat/edit-page/actions.js b/front/src/routes/integration/all/thermostat/edit-page/actions.js index 86d2af4e8d..cae963c9dc 100644 --- a/front/src/routes/integration/all/thermostat/edit-page/actions.js +++ b/front/src/routes/integration/all/thermostat/edit-page/actions.js @@ -46,7 +46,12 @@ function createActions(store) { }); store.setState({ temperatureFeatures, humidityFeatures, switchFeatures, openingFeatures }); } catch (e) { - store.setState({ temperatureFeatures: [], humidityFeatures: [], switchFeatures: [] }); + store.setState({ + temperatureFeatures: [], + humidityFeatures: [], + switchFeatures: [], + openingFeatures: [] + }); } }, @@ -147,11 +152,13 @@ function createActions(store) { const humidityFeature = state.thermostatEditHumidityFeature || ''; const switchFeature = state.thermostatEditSwitchFeature || ''; const windowFeature = state.thermostatEditWindowFeature || ''; - const presetFrost = state.thermostatEditPresetFrost || '7'; - const presetAway = state.thermostatEditPresetAway || '16'; - const presetEco = state.thermostatEditPresetEco || '18'; - const presetNight = state.thermostatEditPresetNight || '17'; - const presetComfort = state.thermostatEditPresetComfort || '21'; + // `|| default` would discard an explicitly entered 0, which the preset + // inputs accept: toNumber only falls back when the value is not a number. + const presetFrost = String(toNumber(state.thermostatEditPresetFrost, 7)); + const presetAway = String(toNumber(state.thermostatEditPresetAway, 16)); + const presetEco = String(toNumber(state.thermostatEditPresetEco, 18)); + const presetNight = String(toNumber(state.thermostatEditPresetNight, 17)); + const presetComfort = String(toNumber(state.thermostatEditPresetComfort, 21)); const hysteresisStart = toNumber(state.thermostatEditHysteresisStart, 0.5); const hysteresisStop = toNumber(state.thermostatEditHysteresisStop, 0.5); const tpiCycleTime = toInt(state.thermostatEditTpiCycleTime, 30); diff --git a/front/src/routes/integration/all/thermostat/schedule-page/SchedulePage.jsx b/front/src/routes/integration/all/thermostat/schedule-page/SchedulePage.jsx index 929cd0cd3f..b830f2ed5f 100644 --- a/front/src/routes/integration/all/thermostat/schedule-page/SchedulePage.jsx +++ b/front/src/routes/integration/all/thermostat/schedule-page/SchedulePage.jsx @@ -6,6 +6,7 @@ import ThermostatPage from '../ThermostatPage'; import ScheduleEditor from './ScheduleEditor'; import style from './style.css'; import withIntlAsProp from '../../../../../utils/withIntlAsProp'; +import { RequestStatus } from '../../../../../utils/consts'; class SchedulePageComponent extends Component { state = { @@ -62,15 +63,22 @@ class SchedulePageComponent extends Component { }; handleDelete = async selector => { - await this.props.deleteSchedule(selector); - this.setState({ confirmDeleteSelector: null }); + const deleted = await this.props.deleteSchedule(selector); + // Keep the confirmation open on failure: closing it silently would leave the + // schedule in the list with no explanation. + if (deleted) { + this.setState({ confirmDeleteSelector: null }); + } }; render(props, { showEditor, editingSchedule, confirmDeleteSelector }) { const { thermostatSchedules, getSchedulesStatus, deleteScheduleStatus } = props; - const loading = getSchedulesStatus === 'getting'; - const deleting = deleteScheduleStatus === 'getting'; + // The actions store RequestStatus values ('Getting', 'Error'), so comparing + // against lowercase literals never matched. + const loading = getSchedulesStatus === RequestStatus.Getting; + const deleting = deleteScheduleStatus === RequestStatus.Getting; + const deleteFailed = deleteScheduleStatus === RequestStatus.Error; return ( @@ -95,6 +103,12 @@ class SchedulePageComponent extends Component {
+ {deleteFailed && ( +
+ +
+ )} + {loading && (
diff --git a/front/src/routes/integration/all/thermostat/schedule-page/actions.js b/front/src/routes/integration/all/thermostat/schedule-page/actions.js index c2e8dbb042..8719c404b0 100644 --- a/front/src/routes/integration/all/thermostat/schedule-page/actions.js +++ b/front/src/routes/integration/all/thermostat/schedule-page/actions.js @@ -44,8 +44,10 @@ function createActions(store) { await state.httpClient.delete(`/api/v1/service/thermostat/schedule/${selector}`); const schedules = (state.thermostatSchedules || []).filter(s => s.selector !== selector); store.setState({ thermostatSchedules: schedules, deleteScheduleStatus: RequestStatus.Success }); + return true; } catch (e) { store.setState({ deleteScheduleStatus: RequestStatus.Error }); + return false; } }, diff --git a/server/migrations/20260823000000-create-thermostat-schedule.js b/server/migrations/20260823000000-create-thermostat-schedule.js index 0f1347912d..de0c14b96d 100644 --- a/server/migrations/20260823000000-create-thermostat-schedule.js +++ b/server/migrations/20260823000000-create-thermostat-schedule.js @@ -8,6 +8,9 @@ module.exports = { }, name: { allowNull: false, + // Two concurrent creates can both pass the duplicate precheck, so the + // uniqueness has to be enforced by the database as well. + unique: true, type: Sequelize.STRING, }, selector: { diff --git a/server/models/thermostat_schedule.js b/server/models/thermostat_schedule.js index 67b42a83bf..fe2c4565b4 100644 --- a/server/models/thermostat_schedule.js +++ b/server/models/thermostat_schedule.js @@ -11,6 +11,7 @@ module.exports = (sequelize, DataTypes) => { }, name: { allowNull: false, + unique: true, type: DataTypes.STRING, }, selector: { diff --git a/server/services/thermostat/api/thermostat.controller.js b/server/services/thermostat/api/thermostat.controller.js index f220c306ae..a04b1fcde5 100644 --- a/server/services/thermostat/api/thermostat.controller.js +++ b/server/services/thermostat/api/thermostat.controller.js @@ -73,7 +73,15 @@ module.exports = function ThermostatController(thermostatHandler) { */ async function setSetpoint(req, res) { const featureSelector = req.params.feature_selector; - const value = Number(req.body.value); + // Number('') and Number(null) are both 0, so the raw value has to be + // rejected before coercion: an empty body would otherwise be accepted as a + // manual hold at 0 °C. + const rawValue = req.body ? req.body.value : undefined; + if (rawValue === undefined || rawValue === null || rawValue === '') { + res.status(400).json({ error: 'INVALID_VALUE' }); + return; + } + const value = Number(rawValue); if (!Number.isFinite(value)) { res.status(400).json({ error: 'INVALID_VALUE' }); return; diff --git a/server/services/thermostat/lib/thermostat.applySchedules.js b/server/services/thermostat/lib/thermostat.applySchedules.js index aa40f4c46a..45c2828ae4 100644 --- a/server/services/thermostat/lib/thermostat.applySchedules.js +++ b/server/services/thermostat/lib/thermostat.applySchedules.js @@ -16,6 +16,10 @@ const { DEFAULT_TPI_PROPORTIONAL_BAND, DEFAULT_HYSTERESIS_START, DEFAULT_HYSTERESIS_STOP, + MIN_TPI_CYCLE_TIME, + MAX_TPI_CYCLE_TIME, + MIN_TPI_PROPORTIONAL_BAND, + MAX_TPI_PROPORTIONAL_BAND, } = require('../../../utils/thermostatConstants'); const DEFAULT_TIMEZONE = 'Europe/Paris'; @@ -61,6 +65,19 @@ function getSetpointForPreset(preset, config) { return DEFAULT_PRESET_TEMPS[preset] !== undefined ? DEFAULT_PRESET_TEMPS[preset] : FALLBACK_SETPOINT; } +/** + * @description Constrain a number to a closed range. + * @param {number} value - Value to constrain. + * @param {number} min - Lower bound. + * @param {number} max - Upper bound. + * @returns {number} The value, bounded by min and max. + * @example + * clamp(0, 0.5, 10); // 0.5 + */ +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + /** * @description Derive a stable per-thermostat offset inside a TPI cycle. * Without it every thermostat sharing a cycle time switches on at the same @@ -105,8 +122,19 @@ function computeSwitchActive(currentTemp, setpoint, mode, config, currentSwitchO if (config && config.control_type === 'tpi' && mode !== 'cooling') { // Over each cycle, the switch is ON for a fraction of the time proportional // to the temperature error within the proportional band. - const cycleMinutes = toNumber(config.tpi_cycle_time, DEFAULT_TPI_CYCLE_TIME); - const band = toNumber(config.tpi_proportional_band, DEFAULT_TPI_PROPORTIONAL_BAND); + // Clamp to the bounds the edit form advertises. An out-of-range value can + // still reach the database through the API, and a 0 would either divide by + // zero (band) or modulo by zero (cycle), leaving the heater stuck ON or OFF. + const cycleMinutes = clamp( + toNumber(config.tpi_cycle_time, DEFAULT_TPI_CYCLE_TIME), + MIN_TPI_CYCLE_TIME, + MAX_TPI_CYCLE_TIME, + ); + const band = clamp( + toNumber(config.tpi_proportional_band, DEFAULT_TPI_PROPORTIONAL_BAND), + MIN_TPI_PROPORTIONAL_BAND, + MAX_TPI_PROPORTIONAL_BAND, + ); const error = setpoint - currentTemp; const onFraction = Math.min(1, Math.max(0, error / band)); const onMinutes = onFraction * cycleMinutes; diff --git a/server/services/thermostat/lib/thermostat.createSchedule.js b/server/services/thermostat/lib/thermostat.createSchedule.js index 290d84f713..9df728ac5d 100644 --- a/server/services/thermostat/lib/thermostat.createSchedule.js +++ b/server/services/thermostat/lib/thermostat.createSchedule.js @@ -25,19 +25,30 @@ async function createSchedule(scheduleData) { const selector = slugify(`${validated.name}-${Date.now()}`, true); - const created = await db.ThermostatSchedule.create( - { - name: validated.name, - selector, - slots: validated.slots.map((slot) => ({ - day_of_week: slot.day_of_week, - start_time: slot.start_time, - end_time: slot.end_time, - preset: slot.preset, - })), - }, - { include: [{ model: db.ThermostatScheduleSlot, as: 'slots' }] }, - ); + let created; + try { + created = await db.ThermostatSchedule.create( + { + name: validated.name, + selector, + slots: validated.slots.map((slot) => ({ + day_of_week: slot.day_of_week, + start_time: slot.start_time, + end_time: slot.end_time, + preset: slot.preset, + })), + }, + { include: [{ model: db.ThermostatScheduleSlot, as: 'slots' }] }, + ); + } catch (e) { + // The precheck above is not atomic: two concurrent creates can both find no + // duplicate and reach this insert. Report the race the same way, so the + // caller sees one message whichever check caught it. + if (e.name === 'SequelizeUniqueConstraintError') { + throw new Error(`A schedule with the name "${validated.name}" already exists`); + } + throw e; + } const result = await db.ThermostatSchedule.findByPk(created.id, { include: [{ model: db.ThermostatScheduleSlot, as: 'slots' }], diff --git a/server/services/thermostat/lib/thermostat.updateSchedule.js b/server/services/thermostat/lib/thermostat.updateSchedule.js index ac4cf9f8ed..c7a62d7a4c 100644 --- a/server/services/thermostat/lib/thermostat.updateSchedule.js +++ b/server/services/thermostat/lib/thermostat.updateSchedule.js @@ -29,24 +29,34 @@ async function updateSchedule(selector, scheduleData) { } // Replace name + slots atomically: a failure mid-way must not lose the existing slots - await db.sequelize.transaction(async (transaction) => { - await schedule.update({ name: validated.name }, { transaction }); - - await db.ThermostatScheduleSlot.destroy({ where: { schedule_id: schedule.id }, transaction }); - - if (validated.slots.length > 0) { - await db.ThermostatScheduleSlot.bulkCreate( - validated.slots.map((slot) => ({ - schedule_id: schedule.id, - day_of_week: slot.day_of_week, - start_time: slot.start_time, - end_time: slot.end_time, - preset: slot.preset, - })), - { transaction }, - ); + try { + await db.sequelize.transaction(async (transaction) => { + await schedule.update({ name: validated.name }, { transaction }); + + await db.ThermostatScheduleSlot.destroy({ where: { schedule_id: schedule.id }, transaction }); + + if (validated.slots.length > 0) { + await db.ThermostatScheduleSlot.bulkCreate( + validated.slots.map((slot) => ({ + schedule_id: schedule.id, + day_of_week: slot.day_of_week, + start_time: slot.start_time, + end_time: slot.end_time, + preset: slot.preset, + })), + { transaction }, + ); + } + }); + } catch (e) { + // The duplicate check above is not atomic: a concurrent create or rename can + // take the name between the check and this update. Report the race the same + // way, so the caller sees one message whichever check caught it. + if (e.name === 'SequelizeUniqueConstraintError') { + throw new Error(`A schedule with the name "${validated.name}" already exists`); } - }); + throw e; + } const result = await db.ThermostatSchedule.findByPk(schedule.id, { include: [{ model: db.ThermostatScheduleSlot, as: 'slots' }], diff --git a/server/test/services/thermostat/api/thermostat.controller.test.js b/server/test/services/thermostat/api/thermostat.controller.test.js index 1093484dab..17ade524b9 100644 --- a/server/test/services/thermostat/api/thermostat.controller.test.js +++ b/server/test/services/thermostat/api/thermostat.controller.test.js @@ -183,6 +183,52 @@ describe('thermostat.controller', () => { assert.notCalled(handler.setValue); }); + it('should reject an empty value, which Number() would turn into 0', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute( + routes, + route, + { params: { feature_selector: 'thermostat-living-room' }, body: { value: '' } }, + res, + ); + + expect(res.statusCode).to.equal(400); + expect(res.body).to.deep.equal({ error: 'INVALID_VALUE' }); + assert.notCalled(handler.setValue); + }); + + it('should reject a null value, which Number() would turn into 0', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute( + routes, + route, + { params: { feature_selector: 'thermostat-living-room' }, body: { value: null } }, + res, + ); + + expect(res.statusCode).to.equal(400); + expect(res.body).to.deep.equal({ error: 'INVALID_VALUE' }); + assert.notCalled(handler.setValue); + }); + + it('should reject a body with no value at all', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute(routes, route, { params: { feature_selector: 'thermostat-living-room' }, body: {} }, res); + + expect(res.statusCode).to.equal(400); + expect(res.body).to.deep.equal({ error: 'INVALID_VALUE' }); + assert.notCalled(handler.setValue); + }); + it('should refuse to write a feature that does not belong to this service', async () => { const handler = buildHandler(); const routes = ThermostatController(handler); diff --git a/server/test/services/thermostat/lib/thermostat.applySchedules.helpers2.test.js b/server/test/services/thermostat/lib/thermostat.applySchedules.helpers2.test.js index e94f62037f..72efcc4d95 100644 --- a/server/test/services/thermostat/lib/thermostat.applySchedules.helpers2.test.js +++ b/server/test/services/thermostat/lib/thermostat.applySchedules.helpers2.test.js @@ -133,6 +133,28 @@ describe('thermostat.computeSwitchActive - TPI', () => { it('should use the default cycle and band when unset', () => { expect(computeSwitchActive(15, 21, 'heating', { control_type: 'tpi' }, false, 0, '')).to.equal(true); }); + + it('should clamp a zero band instead of dividing by it', () => { + // band = 0 gives error/band = Infinity, which would pin the switch ON for + // any temperature below the setpoint. Clamped to 0.5, a 0.01 error asks for + // 0.2 minute of a 10-minute cycle: below the one-minute floor, so OFF. + const config = { control_type: 'tpi', tpi_cycle_time: 10, tpi_proportional_band: 0 }; + expect(computeSwitchActive(20.99, 21, 'heating', config, false, 0, '')).to.equal(false); + }); + + it('should clamp a zero cycle time instead of taking a modulo of it', () => { + // cycle = 0 makes onMinutes 0 and minuteInCycle NaN: the switch could never + // turn on. Clamped to 5 minutes, a full-band error still asks for ON. + const config = { control_type: 'tpi', tpi_cycle_time: 0, tpi_proportional_band: 2 }; + expect(computeSwitchActive(15, 21, 'heating', config, false, 0, '')).to.equal(true); + }); + + it('should clamp an out-of-range cycle time to the advertised maximum', () => { + // 100000 minutes would make a 1-minute regulation step meaningless; the + // clamp keeps the cycle at 120 minutes, where a full-band error is ON. + const config = { control_type: 'tpi', tpi_cycle_time: 100000, tpi_proportional_band: 2 }; + expect(computeSwitchActive(15, 21, 'heating', config, false, 0, '')).to.equal(true); + }); }); describe('thermostat.computeSwitchActive - hysteresis', () => { diff --git a/server/test/services/thermostat/lib/thermostat.schedules.test.js b/server/test/services/thermostat/lib/thermostat.schedules.test.js index f1a7ec6da3..6aec2fdf9d 100644 --- a/server/test/services/thermostat/lib/thermostat.schedules.test.js +++ b/server/test/services/thermostat/lib/thermostat.schedules.test.js @@ -98,6 +98,42 @@ describe('thermostat.createSchedule', () => { assert.notCalled(db.ThermostatSchedule.create); }); + it('should report a name taken between the precheck and the insert', async () => { + // The precheck is not atomic: a concurrent create can take the name in + // between, and the database unique constraint is what catches it. + const db = buildDb(); + const uniqueError = new Error('Validation error'); + uniqueError.name = 'SequelizeUniqueConstraintError'; + db.ThermostatSchedule.create = fake.rejects(uniqueError); + const { createSchedule } = load('createSchedule', db); + + let error = null; + try { + await createSchedule({ name: 'Work week', slots: [] }); + } catch (e) { + error = e; + } + + expect(error).to.not.equal(null); + expect(error.message).to.contain('already exists'); + }); + + it('should let an unrelated create failure bubble up unchanged', async () => { + const db = buildDb(); + db.ThermostatSchedule.create = fake.rejects(new Error('database is locked')); + const { createSchedule } = load('createSchedule', db); + + let error = null; + try { + await createSchedule({ name: 'Work week', slots: [] }); + } catch (e) { + error = e; + } + + expect(error).to.not.equal(null); + expect(error.message).to.equal('database is locked'); + }); + it('should persist the day coerced by Joi, not the raw string', async () => { const db = buildDb(); const { createSchedule } = load('createSchedule', db); @@ -182,6 +218,44 @@ describe('thermostat.updateSchedule', () => { assert.notCalled(db.ThermostatScheduleSlot.destroy); }); + it('should report a name taken between the duplicate check and the update', async () => { + const db = buildDb({ schedule: { id: 'schedule-id', name: 'Work week' } }); + const uniqueError = new Error('Validation error'); + uniqueError.name = 'SequelizeUniqueConstraintError'; + db.sequelize.transaction = async () => { + throw uniqueError; + }; + const { updateSchedule } = load('updateSchedule', db); + + let error = null; + try { + await updateSchedule('my-schedule', { name: 'Taken', slots: [] }); + } catch (e) { + error = e; + } + + expect(error).to.not.equal(null); + expect(error.message).to.contain('already exists'); + }); + + it('should let an unrelated update failure bubble up unchanged', async () => { + const db = buildDb({ schedule: { id: 'schedule-id', name: 'Work week' } }); + db.sequelize.transaction = async () => { + throw new Error('database is locked'); + }; + const { updateSchedule } = load('updateSchedule', db); + + let error = null; + try { + await updateSchedule('my-schedule', { name: 'Taken', slots: [] }); + } catch (e) { + error = e; + } + + expect(error).to.not.equal(null); + expect(error.message).to.equal('database is locked'); + }); + it('should persist the day coerced by Joi on update too', async () => { const db = buildDb({ schedule: { id: 'schedule-id', name: 'Work week', update: fake.resolves(null) } }); const { updateSchedule } = load('updateSchedule', db); diff --git a/server/utils/thermostatConstants.js b/server/utils/thermostatConstants.js index 055f5fe8fb..4e4d833fbd 100644 --- a/server/utils/thermostatConstants.js +++ b/server/utils/thermostatConstants.js @@ -20,6 +20,14 @@ const DEFAULT_HYSTERESIS_STOP = 0.5; // without the param is regulated exactly as the form displayed it. const DEFAULT_TPI_CYCLE_TIME = 30; const DEFAULT_TPI_PROPORTIONAL_BAND = 2; +// Safety floors for the regulation loop. The edit form advertises the same +// bounds, but an HTML `min` is only a browser hint: a device saved through the +// API can still carry a 0, which would divide by zero in the TPI computation +// (band) or modulo by zero in the cycle position (cycle time). +const MIN_TPI_CYCLE_TIME = 5; +const MAX_TPI_CYCLE_TIME = 120; +const MIN_TPI_PROPORTIONAL_BAND = 0.5; +const MAX_TPI_PROPORTIONAL_BAND = 10; const DEFAULT_MODE = 'heating'; const DEFAULT_CONTROL_TYPE = 'hysteresis'; @@ -43,6 +51,10 @@ module.exports = { DEFAULT_HYSTERESIS_STOP, DEFAULT_TPI_CYCLE_TIME, DEFAULT_TPI_PROPORTIONAL_BAND, + MIN_TPI_CYCLE_TIME, + MAX_TPI_CYCLE_TIME, + MIN_TPI_PROPORTIONAL_BAND, + MAX_TPI_PROPORTIONAL_BAND, DEFAULT_MODE, DEFAULT_CONTROL_TYPE, DEFAULT_MIN_TEMP, From 9df66baf92d382d8d2b9485bf87899dfb723111c Mon Sep 17 00:00:00 2001 From: William Deren Date: Mon, 24 Aug 2026 01:17:44 +0200 Subject: [PATCH 10/29] fix(thermostat): defer the preset write to the end of the drag Dragging the gauge away from 'off' wrote PRESET on pointer-down. setVariable debounces a regulation pass, so a drag longer than the debounce -- or an unmount before the release -- let the loop apply the preset while MANUAL_MODE was still false, starting the heater on a setpoint the user never released. The preset now stays local until _onUp, next to MANUAL_MODE and the setpoint. Listen for pointercancel and touchcancel too: a drag taken over by the browser fires cancel and never up, leaving the listeners armed and the displayed setpoint unwritten. Reject non-numeric setpoints before coercion in the controller: Number() turns ' ', false and [] into 0, so all three reached setValue as a manual hold at 0 degrees. Only a number or a non-blank string goes through now. Make the maximum-cycle test observe the clamp: it used a full-band error, so onFraction was 1 and the helper returned true before cycle timing mattered -- removing the clamp would still have passed. It now uses a partial demand at a timestamp where the clamped 120-minute cycle is OFF while the unclamped one would be ON. Co-Authored-By: Claude Opus 5 --- .../boxs/thermostat/ThermostatBox.jsx | 43 +++++++++++-------- .../thermostat/api/thermostat.controller.js | 10 +++-- .../api/thermostat.controller.test.js | 26 +++++++++++ ...thermostat.applySchedules.helpers2.test.js | 8 ++-- 4 files changed, 62 insertions(+), 25 deletions(-) diff --git a/front/src/components/boxs/thermostat/ThermostatBox.jsx b/front/src/components/boxs/thermostat/ThermostatBox.jsx index 51a5366212..517411e179 100644 --- a/front/src/components/boxs/thermostat/ThermostatBox.jsx +++ b/front/src/components/boxs/thermostat/ThermostatBox.jsx @@ -717,24 +717,21 @@ class ThermostatBox extends Component { e.preventDefault(); const angle = getAngleFromPointer(e, this.svgRef); if (!isAngleInArc(angle)) return; - if (this.state.activePreset === 'off') { - const lastPreset = this.getLastActivePreset(); - this.setState({ - setpoint: this.angleToTemp(angle), - isDragging: true, - isManualMode: true, - activePreset: lastPreset, - manualSetpointOverride: true - }); - this.savePreset(lastPreset); - } else { - this.setState({ - setpoint: this.angleToTemp(angle), - isDragging: true, - isManualMode: true, - manualSetpointOverride: true - }); - } + // Leaving 'off' by dragging the gauge only changes the preset locally here. + // Writing PRESET now would debounce a regulation pass while MANUAL_MODE is + // still false, so a drag lasting longer than the debounce — or an unmount + // before the release — would let the loop apply the preset and start the + // heater without the user ever having released a setpoint. It is written on + // release instead, next to MANUAL_MODE and the setpoint. + const leavingOff = this.state.activePreset === 'off'; + const presetOnRelease = leavingOff ? this.getLastActivePreset() : null; + this.setState({ + setpoint: this.angleToTemp(angle), + isDragging: true, + isManualMode: true, + manualSetpointOverride: true, + ...(leavingOff ? { activePreset: presetOnRelease } : {}) + }); // MANUAL_MODE is written on release, together with the setpoint and the // expiry: writing it here would leave the device in manual mode with no // MANUAL_UNTIL if the box unmounts mid-drag, and the regulation loop would @@ -751,6 +748,9 @@ class ThermostatBox extends Component { }; this._onUp = async () => { this.stopDrag(); + if (presetOnRelease) { + await this.savePreset(presetOnRelease); + } await this.saveManualMode(true); this.sendSetpoint(lastDragSetpoint); this.saveManualSetpoint(lastDragSetpoint); @@ -762,6 +762,11 @@ class ThermostatBox extends Component { window.addEventListener('pointerup', this._onUp); window.addEventListener('touchmove', this._onMove, { passive: false }); window.addEventListener('touchend', this._onUp); + // A drag taken over by the browser (scroll, gesture, window switch) fires + // cancel and never up: without these the listeners would stay armed and the + // setpoint shown on the gauge would never be written. + window.addEventListener('pointercancel', this._onUp); + window.addEventListener('touchcancel', this._onUp); }; stopDrag = () => { @@ -769,6 +774,8 @@ class ThermostatBox extends Component { if (this._onUp) window.removeEventListener('pointerup', this._onUp); if (this._onMove) window.removeEventListener('touchmove', this._onMove); if (this._onUp) window.removeEventListener('touchend', this._onUp); + if (this._onUp) window.removeEventListener('pointercancel', this._onUp); + if (this._onUp) window.removeEventListener('touchcancel', this._onUp); this._onMove = null; this._onUp = null; this.setState({ isDragging: false }); diff --git a/server/services/thermostat/api/thermostat.controller.js b/server/services/thermostat/api/thermostat.controller.js index a04b1fcde5..d2bc8b33a0 100644 --- a/server/services/thermostat/api/thermostat.controller.js +++ b/server/services/thermostat/api/thermostat.controller.js @@ -73,11 +73,13 @@ module.exports = function ThermostatController(thermostatHandler) { */ async function setSetpoint(req, res) { const featureSelector = req.params.feature_selector; - // Number('') and Number(null) are both 0, so the raw value has to be - // rejected before coercion: an empty body would otherwise be accepted as a - // manual hold at 0 °C. + // Number() turns '', ' ', false and [] into 0, so the raw value has to be + // narrowed before coercion: any of them would otherwise be accepted as a + // manual hold at 0 °C. Only a number or a non-blank string may go through. const rawValue = req.body ? req.body.value : undefined; - if (rawValue === undefined || rawValue === null || rawValue === '') { + const isNumber = typeof rawValue === 'number'; + const isNumericString = typeof rawValue === 'string' && rawValue.trim() !== ''; + if (!isNumber && !isNumericString) { res.status(400).json({ error: 'INVALID_VALUE' }); return; } diff --git a/server/test/services/thermostat/api/thermostat.controller.test.js b/server/test/services/thermostat/api/thermostat.controller.test.js index 17ade524b9..3eaac7dd2d 100644 --- a/server/test/services/thermostat/api/thermostat.controller.test.js +++ b/server/test/services/thermostat/api/thermostat.controller.test.js @@ -229,6 +229,32 @@ describe('thermostat.controller', () => { assert.notCalled(handler.setValue); }); + // Number() maps all of these to 0: a whitespace string, a boolean and an + // empty array would otherwise be accepted as a manual hold at 0 °C. + [ + { label: 'a whitespace-only string', value: ' ' }, + { label: 'a boolean', value: false }, + { label: 'an array', value: [] }, + { label: 'an object', value: {} }, + ].forEach(({ label, value }) => { + it(`should reject ${label}, which Number() would turn into 0`, async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute( + routes, + route, + { params: { feature_selector: 'thermostat-living-room' }, body: { value } }, + res, + ); + + expect(res.statusCode).to.equal(400); + expect(res.body).to.deep.equal({ error: 'INVALID_VALUE' }); + assert.notCalled(handler.setValue); + }); + }); + it('should refuse to write a feature that does not belong to this service', async () => { const handler = buildHandler(); const routes = ThermostatController(handler); diff --git a/server/test/services/thermostat/lib/thermostat.applySchedules.helpers2.test.js b/server/test/services/thermostat/lib/thermostat.applySchedules.helpers2.test.js index 72efcc4d95..cb5c6875e4 100644 --- a/server/test/services/thermostat/lib/thermostat.applySchedules.helpers2.test.js +++ b/server/test/services/thermostat/lib/thermostat.applySchedules.helpers2.test.js @@ -150,10 +150,12 @@ describe('thermostat.computeSwitchActive - TPI', () => { }); it('should clamp an out-of-range cycle time to the advertised maximum', () => { - // 100000 minutes would make a 1-minute regulation step meaningless; the - // clamp keeps the cycle at 120 minutes, where a full-band error is ON. + // Half a band of error asks for half the cycle ON. At minute 60 of a cycle + // clamped to 120 minutes, that window is over, so the switch is OFF. Without + // the clamp the 100000-minute cycle would still be inside its 50000-minute + // ON window and the call would return true. const config = { control_type: 'tpi', tpi_cycle_time: 100000, tpi_proportional_band: 2 }; - expect(computeSwitchActive(15, 21, 'heating', config, false, 0, '')).to.equal(true); + expect(computeSwitchActive(20, 21, 'heating', config, false, 60 * 60 * 1000, '')).to.equal(false); }); }); From eb4aa93c8c67de46c54e3ab69b94cc5446f51d83 Mon Sep 17 00:00:00 2001 From: William Deren Date: Mon, 24 Aug 2026 01:42:11 +0200 Subject: [PATCH 11/29] chore(thermostat): drop dead styles and stop the services the tests start manualModeIcon and its manualModePulse keyframes had no consumer left: the manual state is rendered by manualBanner. The animation was only referenced by the dead rule itself. Stop every service a test builds. start() arms a real 60-second setInterval whenever no fake clock is installed, and five tests started a service without ever stopping it, so their timers stayed armed and kept the Node event loop alive; the suite only exited because the npm script passes --exit. Tracking the services in buildService rather than assigning them test by test keeps the tests untouched and covers the ones added later. Checked by running the file without --exit: the previous version had to be killed by timeout, this one exits on its own. Co-Authored-By: Claude Opus 5 --- front/src/components/boxs/thermostat/style.css | 12 ------------ server/test/services/thermostat/index.test.js | 14 ++++++++++++-- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/front/src/components/boxs/thermostat/style.css b/front/src/components/boxs/thermostat/style.css index 28cd747117..585a4285c2 100644 --- a/front/src/components/boxs/thermostat/style.css +++ b/front/src/components/boxs/thermostat/style.css @@ -333,18 +333,6 @@ border-color: #467fcf; } -/* Manual mode icon in SVG */ -.manualModeIcon { - font-size: 18px; - opacity: 0.7; - animation: manualModePulse 2s ease-in-out infinite; -} - -@keyframes manualModePulse { - 0%, 100% { opacity: 0.5; } - 50% { opacity: 0.9; } -} - /* Schedule banner (planning mode) */ .scheduleBanner { display: flex; diff --git a/server/test/services/thermostat/index.test.js b/server/test/services/thermostat/index.test.js index d1811405a4..22a0d9f539 100644 --- a/server/test/services/thermostat/index.test.js +++ b/server/test/services/thermostat/index.test.js @@ -6,6 +6,11 @@ const { fake, assert } = sinon; const { EVENTS } = require('../../../utils/constants'); +// Every service built by a test is tracked here so afterEach can stop it: start() +// arms a real 60-second setInterval whenever no fake clock is installed, and a +// timer left behind keeps the Node event loop alive after the suite is done. +const builtServices = []; + const buildService = ({ applySchedulesFails = false } = {}) => { const handler = { applySchedules: applySchedulesFails ? fake.rejects(new Error('boom')) : fake.resolves(null), @@ -30,11 +35,16 @@ const buildService = ({ applySchedulesFails = false } = {}) => { removeListener: fake.returns(null), }, }; - return { service: ThermostatService(gladys, 'service-id'), gladys, handler, controllers }; + const service = ThermostatService(gladys, 'service-id'); + builtServices.push(service); + return { service, gladys, handler, controllers }; }; describe('ThermostatService', () => { - afterEach(() => { + afterEach(async () => { + // Stop before restoring the sandbox: a fake clock still installed here lets + // stop() clear its own interval instead of leaving a real one armed. + await Promise.all(builtServices.splice(0).map((service) => service.stop())); sinon.restore(); }); From 5643c9c3b4c3d422a43d917b22adc4300f86b47d Mon Sep 17 00:00:00 2001 From: William Deren Date: Mon, 24 Aug 2026 13:45:09 +0200 Subject: [PATCH 12/29] fix(thermostat): address the review feedback on units, manual holds and cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconcile the sensor and thermostat units. The room sensor is a separate device, so a celsius Zigbee probe next to a fahrenheit thermostat had the loop comparing 68 against a 20 setpoint: the heating never started (and, in cooling, never stopped). readTemperatureInThermostatUnit converts the reading from the sensor's declared feature.unit before any comparison, on both the manual and the scheduled path. A sensor with no declared unit is assumed to already be in the thermostat's unit, which is the pre-existing behaviour. The widget does the same, resolving both units before the feature loop — setState is asynchronous, so resolving them inside it converted the first reading against a stale unit. Websocket payloads carry no unit, so the one read from the initial GET is cached for them. Only arm the manual expiry on a thermostat that follows a schedule. Nothing else takes the setpoint over, and the widget only renders a countdown banner for a scheduled thermostat: a timer here reverted to the stored preset after 30 minutes with nothing to announce it, which read as the setpoint jumping on its own. Writing an empty string also clears an expiry left by an earlier schedule-backed hold. Stop writing a preset just because a dashboard was opened. loadMode defaulted to comfort when none was stored, which triggered a regulation pass and started the heating on a thermostat the user had never turned on. The render already handles a null preset. Detach the thermostats before deleting a schedule, so no device keeps a THERMOSTAT_ACTIVE_SCHEDULE param pointing at a row that no longer exists. The manual slot destroy goes with it: the foreign key already carries ON DELETE CASCADE, so a single destroy is enough and there is nothing to keep in a transaction. Add the postUpdate hook, so a window sensor changed through the generic device route drops the cached selectors. Without it the immediate cut-off kept watching the previous sensor until the next create, delete or restart. Validate the middle segment of a runtime variable key against the features this service owns, and refuse a non-string value at the controller. The prefix and suffix alone let anyone create THERMOSTAT__PRESET for a feature that does not exist, and postDelete only cleans up the keys derived from a deleted device's features. Fix the setpoint a scene writes not showing up: MANUAL_MODE_UPDATED often lands before the NEW_STATE carrying the value, and the manual-mode guard then swallowed the event meant to display it. manualSetpointOverride is set only by this widget's own dial and buttons, so it separates a local hold from one the server just announced. Also destructure the slot's real column name in startDuplicate, and commit the service's package-lock.json like the other 38 services do. Co-Authored-By: Claude Opus 5 --- docs/specs/thermostat.md | 14 ++- .../boxs/thermostat/ThermostatBox.jsx | 69 +++++++++-- front/src/config/i18n/de.json | 2 +- front/src/config/i18n/en.json | 2 +- front/src/config/i18n/fr.json | 2 +- .../thermostat/schedule-page/SchedulePage.jsx | 2 +- .../thermostat/api/thermostat.controller.js | 16 ++- server/services/thermostat/lib/index.js | 19 ++- .../lib/thermostat.applySchedules.js | 42 ++++++- .../lib/thermostat.deleteSchedule.js | 9 +- .../lib/thermostat.detachSchedule.js | 40 +++++++ .../thermostat/lib/thermostat.onWindowOpen.js | 20 +++- .../thermostat/lib/thermostat.setValue.js | 22 +++- .../thermostat/lib/thermostat.setVariable.js | 47 +++++++- server/services/thermostat/package-lock.json | 22 ++++ .../api/thermostat.controller.test.js | 40 +++++++ .../thermostat.applySchedules.helpers.test.js | 47 ++++++++ .../lib/thermostat.detachSchedule.test.js | 111 ++++++++++++++++++ .../lib/thermostat.onWindowOpen.test.js | 21 ++++ .../lib/thermostat.regulateDevice.test.js | 72 +++++++++++- .../lib/thermostat.schedules.test.js | 34 ++++-- .../lib/thermostat.setValue.test.js | 36 +++++- .../lib/thermostat.setVariable.test.js | 35 +++++- 23 files changed, 671 insertions(+), 53 deletions(-) create mode 100644 server/services/thermostat/lib/thermostat.detachSchedule.js create mode 100644 server/services/thermostat/package-lock.json create mode 100644 server/test/services/thermostat/lib/thermostat.detachSchedule.test.js diff --git a/docs/specs/thermostat.md b/docs/specs/thermostat.md index 73656229af..ca23dc5baa 100644 --- a/docs/specs/thermostat.md +++ b/docs/specs/thermostat.md @@ -93,11 +93,21 @@ The widget passes that timezone explicitly, read once from `SYSTEM_VARIABLE_NAME - **TPI**: the switch is on for a fraction of each cycle proportional to the error within the proportional band. Heating only — a cooling compressor cannot be pulsed that way, so cooling always falls back to hysteresis. An on-time below one minute is rounded down to off: the regulation step is one minute, and a shorter pulse is both useless and hard on the relay. - **TPI phase.** The position inside the cycle is offset by a hash of the thermostat's feature selector. Without it, every thermostat sharing a cycle time switches on at the same wall-clock minute, stacking the loads. +### C.3 Sensor unit vs thermostat unit + +The room sensor is a **separate device** from the thermostat, so nothing forces the two to share a unit: a Zigbee or Z-Wave probe reporting celsius next to a thermostat set to `THERMOSTAT_TEMP_UNIT = F` is a configuration the edit form allows. Comparing the raw reading to the setpoint would then put 68 against 20 and leave the heating permanently off — or, in cooling, permanently on. + +The reading is therefore converted into the thermostat's unit before any comparison, from the sensor's declared `feature.unit`. A sensor with **no** declared unit is assumed to already be in the thermostat's unit: that is the pre-existing behaviour, and guessing would be worse than not converting. + +The widget does the same on its side, and for the same reason — it renders the reading with the thermostat's unit symbol. The sensor unit is read once from the initial `GET /api/v1/device`; websocket `NEW_STATE` payloads do not carry it, so the value cached from that first read is what later events are converted with. + ## D. Scenes `setValue` is the path taken by `device.set-value` and by the generic device API. Persisting the value alone would not survive: the next regulation pass re-applies the scheduled preset and overwrites it within a minute, so a scene setting 21 °C would either do nothing useful or fight the loop every minute. -An external write is therefore treated as a **manual override**, exactly like turning the dial on the widget: the setpoint is saved, the manual flag and its expiry are set, `MANUAL_MODE_UPDATED` is broadcast and a regulation pass is triggered. The setpoint holds for the device's `THERMOSTAT_MANUAL_DURATION` (30 minutes by default), then the schedule takes over again. The widget's countdown reads the same param, so what it displays is what the server enforces. +An external write is therefore treated as a **manual override**, exactly like turning the dial on the widget: the setpoint is saved, the manual flag is set, `MANUAL_MODE_UPDATED` is broadcast and a regulation pass is triggered. + +The **expiry is only armed when the device follows a schedule** — that is the only case where something would otherwise take the setpoint over. With a schedule, the setpoint holds for the device's `THERMOSTAT_MANUAL_DURATION` (30 minutes by default), then the schedule takes over again; the widget's countdown reads the same param, so what it displays is what the server enforces. Without a schedule the hold is **permanent**, like on a physical thermostat: arming a timer there would silently revert to the stored preset a few minutes later, and the widget only renders a countdown banner for a scheduled thermostat, so nothing would announce it. `POST /api/v1/service/thermostat/setpoint/:feature_selector` goes through the same `setValue`, and only after checking that the named feature is a `thermostat` / `target-temperature` feature **owned by this service** — otherwise any authenticated household member could persist a value on a lock, a cover or a light just by naming its selector. @@ -112,6 +122,8 @@ Slots are validated by Joi before reaching the database (`day_of_week` 0–6, `H A slot ending at `00:00` means end of day. A slot whose end is before its start crosses midnight and is matched in two halves — the start day's evening, then the following day's small hours — which is what makes a single "22:00 → 06:00 night" slot expressible. +Deleting a schedule first **detaches** the thermostats that follow it, dropping their `THERMOSTAT_ACTIVE_SCHEDULE` param. The regulation degrades gracefully on a missing schedule — it falls back on the stored preset — but the device would otherwise keep an orphan reference the edit page cannot resolve, and which a new schedule reusing the selector would silently inherit. The slots themselves go with the schedule through the foreign key's `ON DELETE CASCADE`. + ## F. Out of scope - Fil pilote heaters (`heater` / `pilot-wire-mode`): the actuator picker is `switch` / `binary` only. Additive when it comes. diff --git a/front/src/components/boxs/thermostat/ThermostatBox.jsx b/front/src/components/boxs/thermostat/ThermostatBox.jsx index 517411e179..a977c29ab7 100644 --- a/front/src/components/boxs/thermostat/ThermostatBox.jsx +++ b/front/src/components/boxs/thermostat/ThermostatBox.jsx @@ -2,7 +2,7 @@ import { Component } from 'preact'; import { connect } from 'unistore/preact'; import { Text } from 'preact-i18n'; import { WEBSOCKET_MESSAGE_TYPES, DEVICE_FEATURE_UNITS } from '../../../../../server/utils/constants'; -import { celsiusToFahrenheit } from '../../../../../server/utils/units'; +import { celsiusToFahrenheit, fahrenheitToCelsius } from '../../../../../server/utils/units'; import { DEFAULT_MANUAL_DURATION_MINUTES, DEFAULT_PRESET_TEMPS, @@ -67,7 +67,8 @@ class ThermostatBox extends Component { svgRef = null; timezone = null; - modeInitialized = false; + sensorUnit = null; + thermostatUnit = null; savingPreset = false; lastActivePreset = 'comfort'; expectedSetpoint = null; @@ -107,6 +108,31 @@ class ThermostatBox extends Component { return this.needsFahrenheitConversion() ? celsiusToFahrenheit(temp) : temp; }; + // The room sensor is a separate device from the thermostat, so it can report a + // different unit — a Celsius Zigbee probe next to a Fahrenheit thermostat. + // Everything downstream (the gauge, the "is it heating" hint) works in the + // thermostat's unit, so the reading is brought into it here. The sensor unit + // comes from the initial GET; websocket state events do not carry it. + toThermostatUnit = temp => { + if (temp === null || temp === undefined) return temp; + const sensorUnit = this.sensorUnit; + if (!sensorUnit) return temp; + // Read from the instance field, not from getEffectiveUnit(): the thermostat + // unit is stored through setState in the same pass and would still be stale. + const thermostatUnit = + this.thermostatUnit || + (this.props.user && this.props.user.temperature_unit_preference) || + DEVICE_FEATURE_UNITS.CELSIUS; + if (sensorUnit === thermostatUnit) return temp; + if (sensorUnit === DEVICE_FEATURE_UNITS.CELSIUS && thermostatUnit === DEVICE_FEATURE_UNITS.FAHRENHEIT) { + return celsiusToFahrenheit(temp); + } + if (sensorUnit === DEVICE_FEATURE_UNITS.FAHRENHEIT && thermostatUnit === DEVICE_FEATURE_UNITS.CELSIUS) { + return fahrenheitToCelsius(temp); + } + return temp; + }; + // Get the temperature unit symbol getTempUnit = () => { return this.getEffectiveUnit() === DEVICE_FEATURE_UNITS.FAHRENHEIT ? 'F' : 'C'; @@ -147,17 +173,14 @@ class ThermostatBox extends Component { activePreset = storedPreset; } } - this.modeInitialized = true; } else { - // No schedule or manual mode: use the stored preset + // No schedule or manual mode: use the stored preset. When none is stored + // the widget shows no preset and writes nothing: writing a default here + // would make merely opening a dashboard start the heating on a thermostat + // the user has not turned on yet. The render already handles a null preset. const storedPreset = await this.readThermostatVariable('PRESET'); if (storedPreset) { activePreset = knownPresets.includes(storedPreset) ? storedPreset : 'comfort'; - this.modeInitialized = true; - } else if (!this.modeInitialized) { - this.modeInitialized = true; - activePreset = 'comfort'; - await this.savePreset(activePreset); } } @@ -288,6 +311,20 @@ class ThermostatBox extends Component { device_feature_selectors: selectors }); if (devices && devices.length) { + // Both units must be known before any reading is converted: the two + // features can arrive in any order, and setState is asynchronous, so + // resolving them inside the loop would convert the first reading against + // a stale unit. + const allFeatures = devices.reduce((acc, device) => acc.concat(device.features || []), []); + const thermostatUnitFeature = allFeatures.find(feat => feat.selector === thermostatFeature); + if (thermostatUnitFeature && thermostatUnitFeature.unit) { + this.thermostatUnit = thermostatUnitFeature.unit; + } + const sensorUnitFeature = temperatureFeature + ? allFeatures.find(feat => feat.selector === temperatureFeature) + : null; + this.sensorUnit = (sensorUnitFeature && sensorUnitFeature.unit) || null; + devices.forEach(device => { device.features.forEach(feat => { if (feat.selector === thermostatFeature) { @@ -310,7 +347,7 @@ class ThermostatBox extends Component { feat.last_value !== null && feat.last_value !== undefined ) { - this.setState({ currentTemp: feat.last_value }); + this.setState({ currentTemp: this.toThermostatUnit(feat.last_value) }); } if ( humidityFeature && @@ -351,8 +388,14 @@ class ThermostatBox extends Component { const temperatureFeature = (this.state.remoteConfig && this.state.remoteConfig.temperature_feature) || null; const humidityFeature = (this.state.remoteConfig && this.state.remoteConfig.humidity_feature) || null; if (thermostatFeature && payload.device_feature_selector === thermostatFeature) { - // Don't overwrite setpoint during manual mode - if (!this.state.isManualMode) { + // Don't overwrite a setpoint the user is holding here. `isManualMode` is + // not enough on its own: a scene writing the setpoint puts the device in + // manual mode too, and its MANUAL_MODE_UPDATED often lands before the + // NEW_STATE carrying the new value — the guard would then swallow the very + // event that was supposed to display it, leaving the old setpoint on screen + // until the next refresh. `manualSetpointOverride` is set only by this + // widget's own dial and buttons, so it tells the two apart. + if (!this.state.isManualMode || !this.state.manualSetpointOverride) { // Just left manual mode: drop the in-flight events carrying the old // manual setpoint, and only resume following the device once the value // we just applied comes back. @@ -369,7 +412,7 @@ class ThermostatBox extends Component { } } if (temperatureFeature && payload.device_feature_selector === temperatureFeature) { - this.setState({ currentTemp: payload.last_value }); + this.setState({ currentTemp: this.toThermostatUnit(payload.last_value) }); } if (humidityFeature && payload.device_feature_selector === humidityFeature) { this.setState({ humidity: payload.last_value }); diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index ba31ceeba3..1983cfc5c1 100644 --- a/front/src/config/i18n/de.json +++ b/front/src/config/i18n/de.json @@ -3327,7 +3327,7 @@ "maxTempLabel": "Max. Temperatur", "maxTempPlaceholder": "z.B. 35", "temperatureFeatureLabel": "Temperatursensor", - "temperatureFeatureHelp": "Wähle den Sensor, der die Umgebungstemperatur misst.", + "temperatureFeatureHelp": "Wähle den Sensor, der die Umgebungstemperatur misst. Ein Sensor, der eine andere Einheit als das Thermostat meldet, wird automatisch umgerechnet; bei einem Sensor ohne angegebene Einheit wird die Einheit des Thermostats angenommen.", "humidityFeatureLabel": "Feuchtigkeitssensor", "humidityFeatureHelp": "Optional. Wird im Thermostat-Widget angezeigt.", "switchFeatureLabel": "Schalter (Aktor)", diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index 8fe261fba1..5f995fbae4 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -3327,7 +3327,7 @@ "maxTempLabel": "Max temp", "maxTempPlaceholder": "e.g. 35", "temperatureFeatureLabel": "Temperature sensor", - "temperatureFeatureHelp": "Select the sensor that measures the ambient temperature.", + "temperatureFeatureHelp": "Select the sensor that measures the ambient temperature. A sensor reporting a different unit than the thermostat is converted automatically; a sensor with no declared unit is assumed to use the thermostat's.", "humidityFeatureLabel": "Humidity sensor", "humidityFeatureHelp": "Optional. Displayed in the thermostat widget.", "switchFeatureLabel": "Switch (actuator)", diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 5faa904100..f8f958c2ce 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -3327,7 +3327,7 @@ "maxTempLabel": "Temp. max", "maxTempPlaceholder": "Ex: 35", "temperatureFeatureLabel": "Capteur de température", - "temperatureFeatureHelp": "Sélectionnez le capteur qui mesure la température ambiante.", + "temperatureFeatureHelp": "Sélectionnez le capteur qui mesure la température ambiante. Un capteur qui remonte une unité différente de celle du thermostat est converti automatiquement ; un capteur sans unité déclarée est supposé utiliser celle du thermostat.", "humidityFeatureLabel": "Capteur d'humidité", "humidityFeatureHelp": "Optionnel. Affiché dans le widget thermostat.", "switchFeatureLabel": "Commutateur (actionneur)", diff --git a/front/src/routes/integration/all/thermostat/schedule-page/SchedulePage.jsx b/front/src/routes/integration/all/thermostat/schedule-page/SchedulePage.jsx index b830f2ed5f..2f0fb5c6a1 100644 --- a/front/src/routes/integration/all/thermostat/schedule-page/SchedulePage.jsx +++ b/front/src/routes/integration/all/thermostat/schedule-page/SchedulePage.jsx @@ -40,7 +40,7 @@ class SchedulePageComponent extends Component { id: undefined, selector: null, name: `${schedule.name} ${copySuffix}`, - slots: schedule.slots ? schedule.slots.map(({ id, thermostat_schedule_id, ...rest }) => ({ ...rest })) : [] + slots: schedule.slots ? schedule.slots.map(({ id, schedule_id, ...rest }) => ({ ...rest })) : [] }; this.setState({ showEditor: true, editingSchedule: duplicate }); }; diff --git a/server/services/thermostat/api/thermostat.controller.js b/server/services/thermostat/api/thermostat.controller.js index d2bc8b33a0..8bda2e85c0 100644 --- a/server/services/thermostat/api/thermostat.controller.js +++ b/server/services/thermostat/api/thermostat.controller.js @@ -132,7 +132,21 @@ module.exports = function ThermostatController(thermostatHandler) { res.status(400).json({ error: 'INVALID_VARIABLE_KEY' }); return; } - const variable = await thermostatHandler.setVariable(req.params.variable_key, req.body.value); + // The variable table stores text: an object would reach `variable.setValue` + // as-is and come back out as "[object Object]" on the next read. + const rawValue = req.body ? req.body.value : undefined; + if (typeof rawValue !== 'string') { + res.status(400).json({ error: 'INVALID_VALUE' }); + return; + } + // The key must name a feature this service owns; the shape check above only + // covers the prefix and the suffix. + const owned = await thermostatHandler.resolveRuntimeVariableKey(req.params.variable_key); + if (!owned) { + res.status(404).json({ error: 'FEATURE_NOT_FOUND' }); + return; + } + const variable = await thermostatHandler.setVariable(req.params.variable_key, rawValue); res.json(variable); } diff --git a/server/services/thermostat/lib/index.js b/server/services/thermostat/lib/index.js index 64e38f3682..3dbf928e83 100644 --- a/server/services/thermostat/lib/index.js +++ b/server/services/thermostat/lib/index.js @@ -4,11 +4,23 @@ const { getSchedules } = require('./thermostat.getSchedules'); const { createSchedule } = require('./thermostat.createSchedule'); const { updateSchedule } = require('./thermostat.updateSchedule'); const { deleteSchedule } = require('./thermostat.deleteSchedule'); +const { detachSchedule } = require('./thermostat.detachSchedule'); const { applySchedules } = require('./thermostat.applySchedules'); -const { onDeviceNewState, getWindowSelectors, invalidateWindowCache } = require('./thermostat.onWindowOpen'); +const { + onDeviceNewState, + getWindowSelectors, + invalidateWindowCache, + postUpdate, +} = require('./thermostat.onWindowOpen'); const { setValue } = require('./thermostat.setValue'); const { postDelete } = require('./thermostat.postDelete'); -const { setVariable, getVariable, broadcastConfigUpdated, triggerApplySchedules } = require('./thermostat.setVariable'); +const { + setVariable, + getVariable, + resolveRuntimeVariableKey, + broadcastConfigUpdated, + triggerApplySchedules, +} = require('./thermostat.setVariable'); const ThermostatHandler = function ThermostatHandler(gladys, serviceId) { this.gladys = gladys; @@ -25,14 +37,17 @@ ThermostatHandler.prototype.getSchedules = getSchedules; ThermostatHandler.prototype.createSchedule = createSchedule; ThermostatHandler.prototype.updateSchedule = updateSchedule; ThermostatHandler.prototype.deleteSchedule = deleteSchedule; +ThermostatHandler.prototype.detachSchedule = detachSchedule; ThermostatHandler.prototype.applySchedules = applySchedules; ThermostatHandler.prototype.onDeviceNewState = onDeviceNewState; ThermostatHandler.prototype.getWindowSelectors = getWindowSelectors; ThermostatHandler.prototype.invalidateWindowCache = invalidateWindowCache; +ThermostatHandler.prototype.postUpdate = postUpdate; ThermostatHandler.prototype.setValue = setValue; ThermostatHandler.prototype.postDelete = postDelete; ThermostatHandler.prototype.setVariable = setVariable; ThermostatHandler.prototype.getVariable = getVariable; +ThermostatHandler.prototype.resolveRuntimeVariableKey = resolveRuntimeVariableKey; ThermostatHandler.prototype.broadcastConfigUpdated = broadcastConfigUpdated; ThermostatHandler.prototype.triggerApplySchedules = triggerApplySchedules; diff --git a/server/services/thermostat/lib/thermostat.applySchedules.js b/server/services/thermostat/lib/thermostat.applySchedules.js index 45c2828ae4..645eb61d42 100644 --- a/server/services/thermostat/lib/thermostat.applySchedules.js +++ b/server/services/thermostat/lib/thermostat.applySchedules.js @@ -6,7 +6,9 @@ const { SYSTEM_VARIABLE_NAMES, DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, } = require('../../../utils/constants'); +const { celsiusToFahrenheit, fahrenheitToCelsius } = require('../../../utils/units'); const { toNumber, getDeviceConfig, getFeatureBySelector } = require('./thermostat.deviceConfig'); const { parseEnd, findMatchingPreset, getCurrentDayAndMinutes } = require('../../../utils/thermostatSchedule'); const { @@ -46,6 +48,36 @@ function getThermostatFeature(device) { ); } +/** + * @description Read a temperature sensor in the thermostat's own unit. + * The sensor and the thermostat are configured independently: a Zigbee or + * Z-Wave probe reports celsius while the thermostat may be set to fahrenheit, + * and comparing 68 against a 20 setpoint would leave the heating permanently + * off (or, in cooling, permanently on). A sensor with no declared unit is + * assumed to already be in the thermostat's unit — that is the pre-existing + * behaviour, and guessing otherwise would be worse than not converting. + * @param {object} feature - Temperature device feature. + * @param {string} thermostatUnit - Thermostat unit param, 'C' or 'F'. + * @returns {number|null} The reading expressed in the thermostat's unit, or null. + * @example + * const temp = readTemperatureInThermostatUnit(feature, 'F'); + */ +function readTemperatureInThermostatUnit(feature, thermostatUnit) { + const value = feature ? feature.last_value : null; + if (value === null || value === undefined) { + return null; + } + const wantsFahrenheit = thermostatUnit === 'F'; + const sensorUnit = feature.unit || null; + if (sensorUnit === DEVICE_FEATURE_UNITS.CELSIUS && wantsFahrenheit) { + return celsiusToFahrenheit(value); + } + if (sensorUnit === DEVICE_FEATURE_UNITS.FAHRENHEIT && !wantsFahrenheit) { + return fahrenheitToCelsius(value); + } + return value; +} + /** * @description Get setpoint temperature for a preset from config. * @param {string} preset - Preset name. @@ -287,9 +319,10 @@ async function regulateDevice(gladys, device, dayOfWeek, currentMinutes, service if (manualSetpoint !== null && config.switch_feature && config.temperature_feature) { const tmp = await getFeatureBySelector(gladys, config.temperature_feature); const sw = await getFeatureBySelector(gladys, config.switch_feature); - if (tmp && sw && tmp.feature.last_value !== null) { + const manualTemp = tmp ? readTemperatureInThermostatUnit(tmp.feature, config.temp_unit) : null; + if (tmp && sw && manualTemp !== null) { const shouldBeActive = computeSwitchActive( - tmp.feature.last_value, + manualTemp, manualSetpoint, mode, config, @@ -301,7 +334,7 @@ async function regulateDevice(gladys, device, dayOfWeek, currentMinutes, service gladys, config.switch_feature, shouldBeActive, - `manual, setpoint=${manualSetpoint}, temp=${tmp.feature.last_value}, ${selector}`, + `manual, setpoint=${manualSetpoint}, temp=${manualTemp}, ${selector}`, ); } } @@ -376,7 +409,7 @@ async function regulateDevice(gladys, device, dayOfWeek, currentMinutes, service let currentTemp = null; try { const tmp = await getFeatureBySelector(gladys, config.temperature_feature); - currentTemp = tmp ? tmp.feature.last_value : null; + currentTemp = tmp ? readTemperatureInThermostatUnit(tmp.feature, config.temp_unit) : null; } catch (e) { logger.warn(`Thermostat schedule: Failed to read temperature: ${e.message}`); return; @@ -451,6 +484,7 @@ async function applySchedules() { module.exports = { applySchedules, getThermostatFeature, + readTemperatureInThermostatUnit, phaseOffset, regulateDevice, parseEnd, diff --git a/server/services/thermostat/lib/thermostat.deleteSchedule.js b/server/services/thermostat/lib/thermostat.deleteSchedule.js index e5dcc77cee..21da5ffbfe 100644 --- a/server/services/thermostat/lib/thermostat.deleteSchedule.js +++ b/server/services/thermostat/lib/thermostat.deleteSchedule.js @@ -3,6 +3,12 @@ const logger = require('../../../utils/logger'); /** * @description Delete a thermostat schedule and all its slots. + * The slots go with the schedule through the foreign key's ON DELETE CASCADE, + * so a single destroy is enough and there is nothing to keep in a transaction. + * The thermostats that follow this schedule are detached first: leaving + * THERMOSTAT_ACTIVE_SCHEDULE pointing at a deleted row degrades gracefully + * (regulation falls back on the preset) but the device would keep an orphan + * reference that the edit page cannot resolve. * @param {string} selector - Schedule selector. * @returns {Promise} * @example @@ -14,7 +20,8 @@ async function deleteSchedule(selector) { if (!schedule) { throw new Error(`Schedule not found: ${selector}`); } - await db.ThermostatScheduleSlot.destroy({ where: { schedule_id: schedule.id } }); + + await this.detachSchedule(selector); await schedule.destroy(); } diff --git a/server/services/thermostat/lib/thermostat.detachSchedule.js b/server/services/thermostat/lib/thermostat.detachSchedule.js new file mode 100644 index 0000000000..b793c7ef48 --- /dev/null +++ b/server/services/thermostat/lib/thermostat.detachSchedule.js @@ -0,0 +1,40 @@ +const logger = require('../../../utils/logger'); + +const ACTIVE_SCHEDULE_PARAM = 'THERMOSTAT_ACTIVE_SCHEDULE'; + +/** + * @description Drop the THERMOSTAT_ACTIVE_SCHEDULE param of every thermostat + * that follows the given schedule. Called before a schedule is deleted: the + * regulation degrades gracefully on a missing schedule (it falls back on the + * stored preset), but the device would keep pointing at a row that no longer + * exists, which the edit page cannot resolve and which would be silently + * re-adopted by a new schedule reusing the selector. + * @param {string} scheduleSelector - Selector of the schedule being removed. + * @returns {Promise} How many thermostats were detached. + * @example + * await thermostatHandler.detachSchedule('my-schedule'); + */ +async function detachSchedule(scheduleSelector) { + const devices = await this.gladys.device.get({ service: 'thermostat' }); + const following = (devices || []).filter((device) => + (device.params || []).some((param) => param.name === ACTIVE_SCHEDULE_PARAM && param.value === scheduleSelector), + ); + + await Promise.all( + following.map(async (device) => { + try { + await this.gladys.device.destroyParam(device, ACTIVE_SCHEDULE_PARAM); + logger.info(`Thermostat: detached "${device.selector}" from deleted schedule "${scheduleSelector}"`); + } catch (e) { + logger.warn(`Thermostat: could not detach "${device.selector}" from "${scheduleSelector}": ${e.message}`); + } + }), + ); + + if (following.length > 0) { + this.broadcastConfigUpdated(); + } + return following.length; +} + +module.exports = { detachSchedule, ACTIVE_SCHEDULE_PARAM }; diff --git a/server/services/thermostat/lib/thermostat.onWindowOpen.js b/server/services/thermostat/lib/thermostat.onWindowOpen.js index c677e36166..1e38c13ea5 100644 --- a/server/services/thermostat/lib/thermostat.onWindowOpen.js +++ b/server/services/thermostat/lib/thermostat.onWindowOpen.js @@ -4,7 +4,8 @@ const { buildParamsConfig, getFeatureBySelector } = require('./thermostat.device /** * @description Invalidate the cached window-sensor selectors. Called whenever a - * thermostat device is created or deleted, so the next event rebuilds the map. + * thermostat device is created, updated or deleted, so the next event rebuilds + * the map. * @returns {undefined} * @example * thermostatHandler.invalidateWindowCache(); @@ -13,6 +14,21 @@ function invalidateWindowCache() { this.windowSelectorsCache = null; } +/** + * @description Called after a thermostat device is updated. A device saved + * through the generic device route can carry a new THERMOSTAT_WINDOW_FEATURE, + * and the cached selectors would keep pointing at the previous sensor until the + * next create or delete: the immediate cut-off on window opening would ignore + * the new sensor entirely (the minute loop re-reads the params on every tick and + * is not affected). + * @returns {undefined} + * @example + * thermostatHandler.postUpdate(); + */ +function postUpdate() { + this.invalidateWindowCache(); +} + /** * @description The set of window-sensor selectors configured on the thermostats. * EVENTS.DEVICE.NEW_STATE fires for every feature in the house, so without this @@ -108,4 +124,4 @@ async function onDeviceNewState(event) { } } -module.exports = { onDeviceNewState, getWindowSelectors, invalidateWindowCache }; +module.exports = { onDeviceNewState, getWindowSelectors, invalidateWindowCache, postUpdate }; diff --git a/server/services/thermostat/lib/thermostat.setValue.js b/server/services/thermostat/lib/thermostat.setValue.js index dd99da7a67..6b48788147 100644 --- a/server/services/thermostat/lib/thermostat.setValue.js +++ b/server/services/thermostat/lib/thermostat.setValue.js @@ -9,9 +9,14 @@ const { buildParamsConfig, toNumber } = require('./thermostat.deviceConfig'); * API. Persisting the value alone would not survive: the next regulation pass * re-applies the scheduled preset and overwrites it within a minute. So an * external write is treated as a manual override, exactly like turning the dial - * on the widget — the setpoint holds for the device's configured manual - * duration (THERMOSTAT_MANUAL_DURATION, in minutes), then the schedule takes - * over again. + * on the widget. + * + * The expiry is only armed when the device follows a schedule: that is the only + * case where something would otherwise take the setpoint over. Without a + * schedule the hold is permanent, like on a physical thermostat — arming a timer + * there would silently revert to the stored preset after a few minutes, with no + * countdown banner to announce it (the widget only renders one for a scheduled + * thermostat). * @param {object} device - The device object. * @param {object} deviceFeature - The device feature to update. * @param {number} value - The new value. @@ -28,10 +33,12 @@ async function setValue(device, deviceFeature, value) { const manualSetpointKey = `THERMOSTAT_${featureKey}_MANUAL_SETPOINT`; const config = buildParamsConfig(device) || {}; const durationMinutes = toNumber(config.manual_duration, DEFAULT_MANUAL_DURATION_MINUTES); - const manualUntil = Date.now() + durationMinutes * 60 * 1000; + // An empty string clears any expiry left by a previous schedule-backed hold: + // the regulation loop only expires the override when this variable is set. + const manualUntil = config.active_schedule ? String(Date.now() + durationMinutes * 60 * 1000) : ''; await this.gladys.variable.setValue(manualSetpointKey, JSON.stringify({ setpoint: value }), this.serviceId); - await this.gladys.variable.setValue(manualUntilKey, String(manualUntil), this.serviceId); + await this.gladys.variable.setValue(manualUntilKey, manualUntil, this.serviceId); await this.gladys.variable.setValue(manualVarKey, 'true', this.serviceId); this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { @@ -39,7 +46,10 @@ async function setValue(device, deviceFeature, value) { payload: { key: manualVarKey, value: 'true' }, }); - logger.info(`Thermostat: external setValue on ${deviceFeature.selector} held as manual setpoint ${value}`); + logger.info( + `Thermostat: external setValue on ${deviceFeature.selector} held as manual setpoint ${value}` + + `${manualUntil ? ` until ${new Date(Number(manualUntil)).toISOString()}` : ' (no schedule, no expiry)'}`, + ); this.triggerApplySchedules(); } diff --git a/server/services/thermostat/lib/thermostat.setVariable.js b/server/services/thermostat/lib/thermostat.setVariable.js index 85022600e0..a9ed519920 100644 --- a/server/services/thermostat/lib/thermostat.setVariable.js +++ b/server/services/thermostat/lib/thermostat.setVariable.js @@ -9,7 +9,9 @@ const APPLY_DEBOUNCE_MS = 2000; const RUNTIME_SUFFIXES = ['PRESET', 'PRESET_FALLBACK', 'MANUAL_MODE', 'MANUAL_UNTIL', 'MANUAL_SETPOINT']; /** - * @description Whether a variable key is a thermostat runtime key this service owns. + * @description Whether a variable key has the shape of a thermostat runtime key. + * This only checks the prefix and the suffix; whether the middle segment names a + * feature this service actually owns is settled by `resolveRuntimeVariableKey`. * @param {string} variableKey - Variable key to check. * @returns {boolean} True when the key is a known runtime key. * @example @@ -22,6 +24,42 @@ function isRuntimeVariableKey(variableKey) { return RUNTIME_SUFFIXES.some((suffix) => variableKey.endsWith(`_${suffix}`)); } +/** + * @description Turn a feature selector into the middle segment of its runtime keys. + * @param {string} selector - Device feature selector. + * @returns {string} The upper-cased, underscore-separated segment. + * @example + * featureKeyFromSelector('living-room-thermostat'); // 'LIVING_ROOM_THERMOSTAT' + */ +function featureKeyFromSelector(selector) { + return selector.toUpperCase().replace(/-/g, '_'); +} + +/** + * @description Check that a runtime key names a feature owned by this service. + * The prefix and suffix alone are not enough: THERMOSTAT_ANYTHING_PRESET would + * pass, create a row for a feature that does not exist, and stay there forever — + * `postDelete` only cleans up the keys derived from a deleted device's features. + * @param {string} variableKey - Variable key, THERMOSTAT__. + * @returns {Promise} True when the key belongs to one of this service's features. + * @example + * await thermostatHandler.resolveRuntimeVariableKey('THERMOSTAT_LIVING_ROOM_PRESET'); + */ +async function resolveRuntimeVariableKey(variableKey) { + if (!isRuntimeVariableKey(variableKey)) { + return false; + } + const suffix = RUNTIME_SUFFIXES.find((candidate) => variableKey.endsWith(`_${candidate}`)); + const featureKey = variableKey.slice('THERMOSTAT_'.length, variableKey.length - `_${suffix}`.length); + if (featureKey.length === 0) { + return false; + } + const devices = await this.gladys.device.get({ service: 'thermostat' }); + return (devices || []).some((device) => + (device.features || []).some((feature) => featureKeyFromSelector(feature.selector) === featureKey), + ); +} + /** * @description Set a thermostat runtime variable, broadcast the matching websocket * message so every open dashboard refreshes, and schedule a debounced regulation pass. @@ -33,7 +71,8 @@ function isRuntimeVariableKey(variableKey) { * await thermostatHandler.setVariable('THERMOSTAT_MY_DEVICE_PRESET', 'comfort'); */ async function setVariable(variableKey, value) { - if (!isRuntimeVariableKey(variableKey)) { + const owned = await this.resolveRuntimeVariableKey(variableKey); + if (!owned) { throw new Error(`Invalid thermostat variable key: ${variableKey}`); } // Scoped to this service: unscoped rows sit in the global variable table and @@ -81,7 +120,8 @@ function broadcastConfigUpdated() { * await thermostatHandler.getVariable('THERMOSTAT_LIVING_ROOM_PRESET'); */ async function getVariable(variableKey) { - if (!isRuntimeVariableKey(variableKey)) { + const owned = await this.resolveRuntimeVariableKey(variableKey); + if (!owned) { return null; } return this.gladys.variable.getValue(variableKey, this.serviceId); @@ -114,6 +154,7 @@ function triggerApplySchedules() { module.exports = { setVariable, getVariable, + resolveRuntimeVariableKey, broadcastConfigUpdated, triggerApplySchedules, isRuntimeVariableKey, diff --git a/server/services/thermostat/package-lock.json b/server/services/thermostat/package-lock.json new file mode 100644 index 0000000000..cdcf185f0d --- /dev/null +++ b/server/services/thermostat/package-lock.json @@ -0,0 +1,22 @@ +{ + "name": "gladys-thermostat", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gladys-thermostat", + "version": "1.0.0", + "cpu": [ + "x64", + "arm", + "arm64" + ], + "os": [ + "darwin", + "linux", + "win32" + ] + } + } +} diff --git a/server/test/services/thermostat/api/thermostat.controller.test.js b/server/test/services/thermostat/api/thermostat.controller.test.js index 3eaac7dd2d..b74dc2349e 100644 --- a/server/test/services/thermostat/api/thermostat.controller.test.js +++ b/server/test/services/thermostat/api/thermostat.controller.test.js @@ -39,6 +39,7 @@ const buildHandler = (overrides = {}) => ({ setValue: fake.resolves(null), setVariable: fake.resolves({ value: 'comfort' }), getVariable: fake.resolves('comfort'), + resolveRuntimeVariableKey: fake.resolves(true), broadcastConfigUpdated: fake.returns(null), triggerApplySchedules: fake.returns(null), ...overrides, @@ -342,6 +343,45 @@ describe('thermostat.controller', () => { assert.notCalled(handler.setVariable); }); + it('should reject a key naming a feature this service does not own', async () => { + // The shape is right, so only the ownership lookup can catch it: the row + // would otherwise be created for a feature that does not exist and never + // cleaned up. + const handler = buildHandler({ resolveRuntimeVariableKey: fake.resolves(false) }); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute( + routes, + route, + { params: { variable_key: 'THERMOSTAT_GHOST_PRESET' }, body: { value: 'eco' } }, + res, + ); + + expect(res.statusCode).to.equal(404); + expect(res.body).to.deep.equal({ error: 'FEATURE_NOT_FOUND' }); + assert.notCalled(handler.setVariable); + }); + + it('should reject a value that is not a string', async () => { + // The variable table stores text: an object would come back out of a later + // read as "[object Object]". + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute( + routes, + route, + { params: { variable_key: 'THERMOSTAT_X_PRESET' }, body: { value: { a: 1 } } }, + res, + ); + + expect(res.statusCode).to.equal(400); + expect(res.body).to.deep.equal({ error: 'INVALID_VALUE' }); + assert.notCalled(handler.setVariable); + }); + it('should reject a key outside the THERMOSTAT_ namespace', async () => { const handler = buildHandler(); const routes = ThermostatController(handler); diff --git a/server/test/services/thermostat/lib/thermostat.applySchedules.helpers.test.js b/server/test/services/thermostat/lib/thermostat.applySchedules.helpers.test.js index 01322a23d8..ec9351ed18 100644 --- a/server/test/services/thermostat/lib/thermostat.applySchedules.helpers.test.js +++ b/server/test/services/thermostat/lib/thermostat.applySchedules.helpers.test.js @@ -1,10 +1,12 @@ const { expect } = require('chai'); +const { DEVICE_FEATURE_UNITS } = require('../../../../utils/constants'); const { parseEnd, findMatchingPreset, getSetpointForPreset, computeSwitchActive, + readTemperatureInThermostatUnit, } = require('../../../../services/thermostat/lib/thermostat.applySchedules'); describe('thermostat.applySchedules - parseEnd', () => { @@ -193,3 +195,48 @@ describe('thermostat.applySchedules - computeSwitchActive', () => { }); }); }); + +describe('thermostat.applySchedules - readTemperatureInThermostatUnit', () => { + it('should return the reading unchanged when both units match', () => { + const feature = { last_value: 20, unit: DEVICE_FEATURE_UNITS.CELSIUS }; + expect(readTemperatureInThermostatUnit(feature, 'C')).to.equal(20); + }); + + it('should convert a celsius sensor for a fahrenheit thermostat', () => { + // The sensor and the thermostat are two separate devices: comparing 20 + // against a 68 setpoint would leave the heating permanently off. + const feature = { last_value: 20, unit: DEVICE_FEATURE_UNITS.CELSIUS }; + expect(readTemperatureInThermostatUnit(feature, 'F')).to.equal(68); + }); + + it('should convert a fahrenheit sensor for a celsius thermostat', () => { + const feature = { last_value: 68, unit: DEVICE_FEATURE_UNITS.FAHRENHEIT }; + expect(readTemperatureInThermostatUnit(feature, 'C')).to.equal(20); + }); + + it('should leave a fahrenheit sensor alone for a fahrenheit thermostat', () => { + const feature = { last_value: 68, unit: DEVICE_FEATURE_UNITS.FAHRENHEIT }; + expect(readTemperatureInThermostatUnit(feature, 'F')).to.equal(68); + }); + + it('should assume the thermostat unit when the sensor declares none', () => { + // Pre-existing behaviour: guessing would be worse than not converting. + expect(readTemperatureInThermostatUnit({ last_value: 20 }, 'F')).to.equal(20); + expect(readTemperatureInThermostatUnit({ last_value: 68, unit: null }, 'C')).to.equal(68); + }); + + it('should ignore a unit it does not know about', () => { + const feature = { last_value: 20, unit: 'kelvin' }; + expect(readTemperatureInThermostatUnit(feature, 'F')).to.equal(20); + }); + + it('should return null for a missing reading', () => { + expect(readTemperatureInThermostatUnit(null, 'C')).to.equal(null); + expect(readTemperatureInThermostatUnit({ last_value: null }, 'C')).to.equal(null); + expect(readTemperatureInThermostatUnit({ last_value: undefined }, 'C')).to.equal(null); + }); + + it('should keep a 0 reading, which is a legitimate temperature', () => { + expect(readTemperatureInThermostatUnit({ last_value: 0, unit: DEVICE_FEATURE_UNITS.CELSIUS }, 'F')).to.equal(32); + }); +}); diff --git a/server/test/services/thermostat/lib/thermostat.detachSchedule.test.js b/server/test/services/thermostat/lib/thermostat.detachSchedule.test.js new file mode 100644 index 0000000000..d4d1a6991b --- /dev/null +++ b/server/test/services/thermostat/lib/thermostat.detachSchedule.test.js @@ -0,0 +1,111 @@ +const { expect } = require('chai'); +const sinon = require('sinon').createSandbox(); +const proxyquire = require('proxyquire').noCallThru(); + +const { fake, assert } = sinon; + +const load = () => + proxyquire('../../../../services/thermostat/lib/thermostat.detachSchedule', { + '../../../utils/logger': { + debug: fake.returns(null), + info: fake.returns(null), + warn: fake.returns(null), + }, + }); + +const deviceFollowing = (selector, scheduleSelector) => ({ + selector, + params: [{ name: 'THERMOSTAT_ACTIVE_SCHEDULE', value: scheduleSelector }], +}); + +const buildHandler = (devices, { destroyParam = fake.resolves(null) } = {}) => { + const { detachSchedule } = load(); + return { + gladys: { + device: { get: fake.resolves(devices), destroyParam }, + }, + broadcastConfigUpdated: fake.returns(null), + detachSchedule, + }; +}; + +describe('thermostat.detachSchedule', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should drop the active schedule param of every thermostat following it', async () => { + const first = deviceFollowing('thermostat-living-room', 'week'); + const second = deviceFollowing('thermostat-bedroom', 'week'); + const handler = buildHandler([first, second]); + + const detached = await handler.detachSchedule('week'); + + expect(detached).to.equal(2); + assert.calledWith(handler.gladys.device.destroyParam, first, 'THERMOSTAT_ACTIVE_SCHEDULE'); + assert.calledWith(handler.gladys.device.destroyParam, second, 'THERMOSTAT_ACTIVE_SCHEDULE'); + }); + + it('should leave the thermostats following another schedule alone', async () => { + const other = deviceFollowing('thermostat-bedroom', 'weekend'); + const handler = buildHandler([deviceFollowing('thermostat-living-room', 'week'), other]); + + const detached = await handler.detachSchedule('week'); + + expect(detached).to.equal(1); + expect(handler.gladys.device.destroyParam.calledWith(other)).to.equal(false); + }); + + it('should not broadcast when no thermostat followed the schedule', async () => { + const handler = buildHandler([deviceFollowing('thermostat-bedroom', 'weekend')]); + + const detached = await handler.detachSchedule('week'); + + expect(detached).to.equal(0); + assert.notCalled(handler.gladys.device.destroyParam); + assert.notCalled(handler.broadcastConfigUpdated); + }); + + it('should tell the open dashboards to reload once a thermostat was detached', async () => { + const handler = buildHandler([deviceFollowing('thermostat-living-room', 'week')]); + + await handler.detachSchedule('week'); + + assert.calledOnce(handler.broadcastConfigUpdated); + }); + + it('should keep detaching the others when one param cannot be removed', async () => { + // The schedule is deleted right after: one device left with a stale param is + // better than aborting halfway and leaving the rest pointing at it too. + const failing = deviceFollowing('thermostat-living-room', 'week'); + const handler = buildHandler([failing, deviceFollowing('thermostat-bedroom', 'week')], { + destroyParam: fake(async (device) => { + if (device === failing) { + throw new Error('database is locked'); + } + return null; + }), + }); + + const detached = await handler.detachSchedule('week'); + + expect(detached).to.equal(2); + expect(handler.gladys.device.destroyParam.callCount).to.equal(2); + }); + + it('should handle a device with no params at all', async () => { + const handler = buildHandler([{ selector: 'thermostat-living-room' }]); + + const detached = await handler.detachSchedule('week'); + + expect(detached).to.equal(0); + }); + + it('should handle the absence of any thermostat device', async () => { + const handler = buildHandler(null); + + const detached = await handler.detachSchedule('week'); + + expect(detached).to.equal(0); + }); +}); diff --git a/server/test/services/thermostat/lib/thermostat.onWindowOpen.test.js b/server/test/services/thermostat/lib/thermostat.onWindowOpen.test.js index 016af8525f..e2dbc80d7d 100644 --- a/server/test/services/thermostat/lib/thermostat.onWindowOpen.test.js +++ b/server/test/services/thermostat/lib/thermostat.onWindowOpen.test.js @@ -340,6 +340,27 @@ describe('thermostat.onDeviceNewState - window selector cache', () => { expect(gladys.device.get.callCount).to.equal(2); }); + it('should drop the cache when a thermostat is updated', async () => { + // A device saved through the generic device route can carry a new window + // sensor: without postUpdate the immediate cut-off would keep watching the + // previous one until the next create, delete or restart. + const mod = loadModule(); + const { gladys } = buildGladys(); + const handler = { + gladys, + windowSelectorsCache: null, + invalidateWindowCache: mod.invalidateWindowCache, + postUpdate: mod.postUpdate, + }; + + await mod.onDeviceNewState.call(handler, { device_feature: 'some-other-sensor', last_value: 0 }); + expect(handler.windowSelectorsCache).to.not.equal(null); + + handler.postUpdate(); + + expect(handler.windowSelectorsCache).to.equal(null); + }); + it('should expose the configured window selectors', async () => { const mod = loadModule(); const { gladys } = buildGladys(); diff --git a/server/test/services/thermostat/lib/thermostat.regulateDevice.test.js b/server/test/services/thermostat/lib/thermostat.regulateDevice.test.js index 8721eda423..4a82002591 100644 --- a/server/test/services/thermostat/lib/thermostat.regulateDevice.test.js +++ b/server/test/services/thermostat/lib/thermostat.regulateDevice.test.js @@ -4,7 +4,12 @@ const proxyquire = require('proxyquire').noCallThru(); const { fake, assert } = sinon; -const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES, EVENTS } = require('../../../../utils/constants'); +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, + EVENTS, +} = require('../../../../utils/constants'); const { getCurrentDayAndMinutes } = require('../../../../utils/thermostatSchedule'); const setpointFeature = (extra = {}) => ({ @@ -613,4 +618,69 @@ describe('thermostat.regulateDevice - config defaults', () => { assert.notCalled(gladys.device.setValue); }); + + it('should convert a celsius sensor before comparing it to a fahrenheit setpoint', async () => { + // 18 °C is 64.4 °F, well below the 70 °F comfort setpoint, so the heating + // must start. Comparing the raw 18 against 70 would also start it here — the + // cooling case below is the one that proves the conversion actually happens. + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ + features: { + 'temp-sensor': { selector: 'temp-sensor', last_value: 18, unit: DEVICE_FEATURE_UNITS.CELSIUS }, + 'heater-switch': { selector: 'heater-switch', last_value: 0 }, + }, + }); + + await regulate(mod, gladys, { + features: [setpointFeature()], + params: baseParams({ THERMOSTAT_TEMP_UNIT: 'F', THERMOSTAT_PRESET_COMFORT: '70' }), + }); + + const [, , value] = gladys.device.setValue.firstCall.args; + expect(value).to.equal(1); + }); + + it('should not leave a fahrenheit thermostat heating on a warm celsius room', async () => { + // 24 °C is 75.2 °F, above the 70 °F setpoint: the heating must stop. Without + // the conversion the loop would compare 24 against 70 and heat forever. + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ + features: { + 'temp-sensor': { selector: 'temp-sensor', last_value: 24, unit: DEVICE_FEATURE_UNITS.CELSIUS }, + 'heater-switch': { selector: 'heater-switch', last_value: 1 }, + }, + }); + + await regulate(mod, gladys, { + features: [setpointFeature()], + params: baseParams({ THERMOSTAT_TEMP_UNIT: 'F', THERMOSTAT_PRESET_COMFORT: '70' }), + }); + + const [, , value] = gladys.device.setValue.firstCall.args; + expect(value).to.equal(0); + }); + + it('should convert the sensor on the manual override path too', async () => { + // The manual branch returns before the schedule is resolved, so it needs its + // own conversion: 24 °C is 75.2 °F, above a 70 °F manual hold. + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ + features: { + 'temp-sensor': { selector: 'temp-sensor', last_value: 24, unit: DEVICE_FEATURE_UNITS.CELSIUS }, + 'heater-switch': { selector: 'heater-switch', last_value: 1 }, + }, + variables: { + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_MODE: 'true', + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_SETPOINT: JSON.stringify({ setpoint: 70 }), + }, + }); + + await regulate(mod, gladys, { + features: [setpointFeature()], + params: baseParams({ THERMOSTAT_TEMP_UNIT: 'F', THERMOSTAT_PRESET_COMFORT: '70' }), + }); + + const [, , value] = gladys.device.setValue.firstCall.args; + expect(value).to.equal(0); + }); }); diff --git a/server/test/services/thermostat/lib/thermostat.schedules.test.js b/server/test/services/thermostat/lib/thermostat.schedules.test.js index 6aec2fdf9d..75e8a39bf2 100644 --- a/server/test/services/thermostat/lib/thermostat.schedules.test.js +++ b/server/test/services/thermostat/lib/thermostat.schedules.test.js @@ -283,33 +283,47 @@ describe('thermostat.updateSchedule', () => { }); describe('thermostat.deleteSchedule', () => { - it('should delete the schedule and its slots', async () => { - const db = buildDb({ schedule: { id: 'schedule-id', selector: 'my-schedule' } }); + const buildDeleteHandler = (db) => { const { deleteSchedule } = load('deleteSchedule', db); + return { deleteSchedule, detachSchedule: fake.resolves(0) }; + }; - await deleteSchedule('my-schedule'); + it('should delete the schedule, letting the CASCADE take its slots', async () => { + const db = buildDb({ schedule: { id: 'schedule-id', selector: 'my-schedule' } }); + const handler = buildDeleteHandler(db); + + await handler.deleteSchedule('my-schedule'); - assert.calledOnce(db.ThermostatScheduleSlot.destroy); - expect(db.ThermostatScheduleSlot.destroy.firstCall.args[0]).to.deep.equal({ - where: { schedule_id: 'schedule-id' }, - }); assert.calledOnce(db.scheduleInstance.destroy); + // The slot foreign key carries ON DELETE CASCADE, so a manual destroy would + // be redundant — and a non-transactional one at that. + assert.notCalled(db.ThermostatScheduleSlot.destroy); + }); + + it('should detach the thermostats following the schedule before deleting it', async () => { + const db = buildDb({ schedule: { id: 'schedule-id', selector: 'my-schedule' } }); + const handler = buildDeleteHandler(db); + + await handler.deleteSchedule('my-schedule'); + + assert.calledWith(handler.detachSchedule, 'my-schedule'); + expect(handler.detachSchedule.firstCall.calledBefore(db.scheduleInstance.destroy.firstCall)).to.equal(true); }); it('should throw when the schedule does not exist', async () => { const db = buildDb(); - const { deleteSchedule } = load('deleteSchedule', db); + const handler = buildDeleteHandler(db); let error = null; try { - await deleteSchedule('unknown'); + await handler.deleteSchedule('unknown'); } catch (e) { error = e; } expect(error).to.not.equal(null); expect(error.message).to.contain('Schedule not found'); - assert.notCalled(db.ThermostatScheduleSlot.destroy); + assert.notCalled(handler.detachSchedule); }); }); diff --git a/server/test/services/thermostat/lib/thermostat.setValue.test.js b/server/test/services/thermostat/lib/thermostat.setValue.test.js index bb95cbe646..f5e778b272 100644 --- a/server/test/services/thermostat/lib/thermostat.setValue.test.js +++ b/server/test/services/thermostat/lib/thermostat.setValue.test.js @@ -32,6 +32,13 @@ const buildHandler = () => { const deviceFeature = { selector: 'thermostat-living-room' }; +// The expiry is only armed on a thermostat that follows a schedule: without one +// the manual hold is permanent, so most of these assertions need a device that +// carries an active schedule. +const scheduledDevice = (params = []) => ({ + params: [{ name: 'THERMOSTAT_ACTIVE_SCHEDULE', value: 'week' }, ...params], +}); + describe('thermostat.setValue', () => { afterEach(() => { sinon.restore(); @@ -49,7 +56,7 @@ describe('thermostat.setValue', () => { const clock = sinon.useFakeTimers(1_700_000_000_000); const handler = buildHandler(); - await handler.setValue({}, deviceFeature, 21.5); + await handler.setValue(scheduledDevice(), deviceFeature, 21.5); assert.calledWith( handler.gladys.variable.setValue, @@ -99,7 +106,7 @@ describe('thermostat.setValue', () => { it('should hold the setpoint for the duration configured on the device', async () => { const clock = sinon.useFakeTimers(1_700_000_000_000); const handler = buildHandler(); - const device = { params: [{ name: 'THERMOSTAT_MANUAL_DURATION', value: '45' }] }; + const device = scheduledDevice([{ name: 'THERMOSTAT_MANUAL_DURATION', value: '45' }]); await handler.setValue(device, deviceFeature, 21.5); @@ -114,7 +121,7 @@ describe('thermostat.setValue', () => { const clock = sinon.useFakeTimers(1_700_000_000_000); const handler = buildHandler(); - await handler.setValue({ params: [] }, deviceFeature, 21.5); + await handler.setValue(scheduledDevice(), deviceFeature, 21.5); assert.calledWith( handler.gladys.variable.setValue, @@ -123,6 +130,29 @@ describe('thermostat.setValue', () => { ); }); + it('should not arm an expiry on a thermostat without a schedule', async () => { + sinon.useFakeTimers(1_700_000_000_000); + const handler = buildHandler(); + + // Nothing would take the setpoint over, so the hold is permanent — the + // regulation loop only expires the override when MANUAL_UNTIL is set. + await handler.setValue({ params: [] }, deviceFeature, 21.5); + + assert.calledWith(handler.gladys.variable.setValue, 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL', ''); + assert.calledWith(handler.gladys.variable.setValue, 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_MODE', 'true'); + }); + + it('should clear an expiry left by a previous schedule-backed hold', async () => { + sinon.useFakeTimers(1_700_000_000_000); + const handler = buildHandler(); + + // The schedule was removed from the device since the last manual hold: an + // untouched MANUAL_UNTIL would still expire the new, permanent override. + await handler.setValue({ params: [{ name: 'THERMOSTAT_MANUAL_DURATION', value: '45' }] }, deviceFeature, 20); + + assert.calledWith(handler.gladys.variable.setValue, 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL', ''); + }); + it('should write the runtime variables in this service scope', async () => { const handler = buildHandler(); diff --git a/server/test/services/thermostat/lib/thermostat.setVariable.test.js b/server/test/services/thermostat/lib/thermostat.setVariable.test.js index 5d1ea7171f..f842538c6a 100644 --- a/server/test/services/thermostat/lib/thermostat.setVariable.test.js +++ b/server/test/services/thermostat/lib/thermostat.setVariable.test.js @@ -15,17 +15,22 @@ const load = () => }, }); -const buildHandler = () => { - const { setVariable, getVariable, broadcastConfigUpdated, triggerApplySchedules } = load(); +// The keys under test all name the "living-room" feature, so the handler is +// given a thermostat owning it: a key whose middle segment names no feature of +// this service is refused. +const buildHandler = (devices = [{ features: [{ selector: 'living-room' }] }]) => { + const { setVariable, getVariable, resolveRuntimeVariableKey, broadcastConfigUpdated, triggerApplySchedules } = load(); const handler = { gladys: { variable: { setValue: fake.resolves({ value: 'saved' }), getValue: fake.resolves('comfort') }, + device: { get: fake.resolves(devices) }, event: { emit: fake.returns(null) }, }, serviceId: 'service-id', applySchedules: fake.resolves(null), setVariable, getVariable, + resolveRuntimeVariableKey, broadcastConfigUpdated, triggerApplySchedules, }; @@ -70,6 +75,25 @@ describe('thermostat.setVariable', () => { assert.notCalled(handler.gladys.variable.setValue); }); + it('should refuse a key naming a feature this service does not own', async () => { + // The prefix and the suffix are right, so the shape check passes: without + // the ownership check this row would be created for a feature that does not + // exist and would never be cleaned up — postDelete only removes the keys + // derived from a deleted device's features. + const handler = buildHandler([{ features: [{ selector: 'living-room' }] }]); + + let error = null; + try { + await handler.setVariable('THERMOSTAT_GHOST_ROOM_PRESET', 'eco'); + } catch (e) { + error = e; + } + + expect(error).to.not.equal(null); + expect(error.message).to.contain('Invalid thermostat variable key'); + assert.notCalled(handler.gladys.variable.setValue); + }); + it('should broadcast PRESET_UPDATED for a preset variable', async () => { const handler = buildHandler(); @@ -123,6 +147,13 @@ describe('thermostat.getVariable', () => { assert.calledWith(handler.gladys.variable.getValue, 'THERMOSTAT_LIVING_ROOM_PRESET', 'service-id'); }); + it('should return null for a key naming a feature this service does not own', async () => { + const handler = buildHandler([{ features: [{ selector: 'living-room' }] }]); + + expect(await handler.getVariable('THERMOSTAT_GHOST_ROOM_PRESET')).to.equal(null); + assert.notCalled(handler.gladys.variable.getValue); + }); + it('should return null for a key outside the runtime namespace', async () => { const handler = buildHandler(); From 567f2155c252894a14eea26e0ec437d1caf1fd9a Mon Sep 17 00:00:00 2001 From: William Deren Date: Mon, 24 Aug 2026 13:49:20 +0200 Subject: [PATCH 13/29] fix(thermostat): stop the return to the schedule from re-arming manual mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returning to the schedule went through the setpoint route, which treats every write as a manual override: applyPlanningPreset saved MANUAL_MODE = false and then immediately wrote it back to true, along with a MANUAL_UNTIL. The widget ignores its own websocket echo, so it kept displaying the schedule while the database said manual — and a page refresh, which restores its state from the database, came back in manual mode. The stray expiry then dropped it again a configured duration later, so the thermostat also returned to the schedule on its own for no visible reason. selectPreset had the same shape on a thermostat that follows no schedule. The setpoint route now takes an optional manual flag, defaulting to true so scenes, the generic device API, the dial and the +/- buttons are unchanged. The widget passes manual: false in the two places where it writes the setpoint the loop is already going to regulate on, right after clearing the flag. Co-Authored-By: Claude Opus 5 --- docs/specs/thermostat.md | 2 + .../boxs/thermostat/ThermostatBox.jsx | 19 +++++-- .../thermostat/api/thermostat.controller.js | 7 ++- .../thermostat/lib/thermostat.setValue.js | 41 +++++++++++----- .../api/thermostat.controller.test.js | 49 +++++++++++++++++++ .../lib/thermostat.setValue.test.js | 29 +++++++++++ 6 files changed, 130 insertions(+), 17 deletions(-) diff --git a/docs/specs/thermostat.md b/docs/specs/thermostat.md index ca23dc5baa..e965b8f57b 100644 --- a/docs/specs/thermostat.md +++ b/docs/specs/thermostat.md @@ -111,6 +111,8 @@ The **expiry is only armed when the device follows a schedule** — that is the `POST /api/v1/service/thermostat/setpoint/:feature_selector` goes through the same `setValue`, and only after checking that the named feature is a `thermostat` / `target-temperature` feature **owned by this service** — otherwise any authenticated household member could persist a value on a lock, a cover or a light just by naming its selector. +That route accepts an optional `manual` flag, default `true`. The widget passes `manual: false` in exactly two places: when a hold ends and the schedule takes the thermostat back, and when a preset is picked on a thermostat that follows no schedule. Both write the setpoint the loop is *already* going to regulate on, right after saving `MANUAL_MODE = false` — so treating them as overrides would re-arm the very flag they just cleared. The widget would keep showing the schedule while the database said manual, and a page refresh (which restores its state from the database) would come back in manual mode, until the expiry silently dropped it minutes later. Every other caller — scenes, the generic device API, the dial, the +/− buttons — means a manual override and gets the default. + ## E. Weekly schedules Two tables (migration `20260823000000`): diff --git a/front/src/components/boxs/thermostat/ThermostatBox.jsx b/front/src/components/boxs/thermostat/ThermostatBox.jsx index a977c29ab7..e53a23210d 100644 --- a/front/src/components/boxs/thermostat/ThermostatBox.jsx +++ b/front/src/components/boxs/thermostat/ThermostatBox.jsx @@ -582,7 +582,8 @@ class ThermostatBox extends Component { } this.setState(newState); if (newState.setpoint !== undefined) { - this.sendSetpoint(newState.setpoint); + // Not a manual write: this is the schedule taking the thermostat back. + this.sendSetpoint(newState.setpoint, false); } else { // Nothing to apply (preset "off"): release the hold placed by the caller. this.releaseSetpointHold(); @@ -743,11 +744,18 @@ class ThermostatBox extends Component { // triggered every minute and debounced after each variable/setpoint change). } - sendSetpoint = async value => { + // The setpoint route treats a write as a manual override, like a scene would. + // Pass manual: false when writing back the setpoint the schedule dictates — + // otherwise returning to the schedule immediately re-arms the override it is + // clearing, and the widget shows the schedule while the database says manual. + sendSetpoint = async (value, manual = true) => { const { box } = this.props; if (!box.thermostat_feature) return; try { - await this.props.httpClient.post(`/api/v1/service/thermostat/setpoint/${box.thermostat_feature}`, { value }); + await this.props.httpClient.post(`/api/v1/service/thermostat/setpoint/${box.thermostat_feature}`, { + value, + manual + }); } catch (e) { console.error(e); } @@ -882,7 +890,10 @@ class ThermostatBox extends Component { await this.savePreset(preset.key); await this.saveManualMode(newManual); if (preset.temp !== null) { - this.sendSetpoint(preset.temp); + // Without a schedule, picking a preset is not a manual override — the + // preset itself is what the loop regulates on, and marking the write + // manual would contradict the MANUAL_MODE=false just saved above. + this.sendSetpoint(preset.temp, newManual); } if (hasSchedule) this.startManualTimer(newSetpoint); }; diff --git a/server/services/thermostat/api/thermostat.controller.js b/server/services/thermostat/api/thermostat.controller.js index 8bda2e85c0..3752001e22 100644 --- a/server/services/thermostat/api/thermostat.controller.js +++ b/server/services/thermostat/api/thermostat.controller.js @@ -113,7 +113,12 @@ module.exports = function ThermostatController(thermostatHandler) { return; } // Go through setValue so the widget, the API and scenes share one path. - await thermostatHandler.setValue(device, deviceFeature, value); + // `manual: false` is how the widget writes back the scheduled setpoint when a + // hold ends: without it the write would immediately re-arm the very override + // it is clearing. Anything else — a scene, the generic API, the dial — means + // a manual override, so that stays the default. + const manual = req.body.manual !== false; + await thermostatHandler.setValue(device, deviceFeature, value, manual); res.json({ success: true, value }); } diff --git a/server/services/thermostat/lib/thermostat.setValue.js b/server/services/thermostat/lib/thermostat.setValue.js index 6b48788147..4cee3a9813 100644 --- a/server/services/thermostat/lib/thermostat.setValue.js +++ b/server/services/thermostat/lib/thermostat.setValue.js @@ -5,28 +5,45 @@ const { buildParamsConfig, toNumber } = require('./thermostat.deviceConfig'); /** * @description Set a thermostat device feature value (for example the setpoint). - * This is the path taken by scenes (`device.set-value`) and by the generic device - * API. Persisting the value alone would not survive: the next regulation pass - * re-applies the scheduled preset and overwrites it within a minute. So an - * external write is treated as a manual override, exactly like turning the dial - * on the widget. + * This is the path taken by scenes (`device.set-value`), by the generic device + * API and by the widget. Persisting the value alone would not survive: the next + * regulation pass re-applies the scheduled preset and overwrites it within a + * minute. So an external write is treated as a manual override, exactly like + * turning the dial on the widget. * - * The expiry is only armed when the device follows a schedule: that is the only - * case where something would otherwise take the setpoint over. Without a - * schedule the hold is permanent, like on a physical thermostat — arming a timer - * there would silently revert to the stored preset after a few minutes, with no - * countdown banner to announce it (the widget only renders one for a scheduled - * thermostat). + * The widget also uses this path to write back the *scheduled* setpoint when a + * manual hold ends. That write must not re-arm the override it is clearing, so + * `manual` can be turned off: the value is then persisted alone, exactly like a + * plain `saveState`. It defaults to true, which is what scenes and the generic + * device API mean when they write a setpoint. + * + * On a manual write, the expiry is only armed when the device follows a + * schedule: that is the only case where something would otherwise take the + * setpoint over. Without a schedule the hold is permanent, like on a physical + * thermostat — arming a timer there would silently revert to the stored preset + * after a few minutes, with no countdown banner to announce it (the widget only + * renders one for a scheduled thermostat). * @param {object} device - The device object. * @param {object} deviceFeature - The device feature to update. * @param {number} value - The new value. + * @param {boolean} [manual] - Whether this write is a manual override. Default true. * @returns {Promise} * @example * await service.device.setValue(device, deviceFeature, 21.5); */ -async function setValue(device, deviceFeature, value) { +async function setValue(device, deviceFeature, value, manual = true) { await this.gladys.device.saveState(deviceFeature, value); + if (!manual) { + // Returning to the schedule: the caller has already cleared the manual flag, + // and re-arming it here would leave the device in manual mode in the database + // while every open widget displays the schedule — until the expiry silently + // dropped it again, minutes later. + logger.info(`Thermostat: scheduled setpoint ${value} written on ${deviceFeature.selector}`); + this.triggerApplySchedules(); + return; + } + const featureKey = deviceFeature.selector.toUpperCase().replace(/-/g, '_'); const manualVarKey = `THERMOSTAT_${featureKey}_MANUAL_MODE`; const manualUntilKey = `THERMOSTAT_${featureKey}_MANUAL_UNTIL`; diff --git a/server/test/services/thermostat/api/thermostat.controller.test.js b/server/test/services/thermostat/api/thermostat.controller.test.js index b74dc2349e..edf0b09230 100644 --- a/server/test/services/thermostat/api/thermostat.controller.test.js +++ b/server/test/services/thermostat/api/thermostat.controller.test.js @@ -308,6 +308,55 @@ describe('thermostat.controller', () => { }); }); + describe('setSetpoint manual flag', () => { + const route = 'post /api/v1/service/thermostat/setpoint/:feature_selector'; + + it('should treat a write as manual by default', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute( + routes, + route, + { params: { feature_selector: 'thermostat-living-room' }, body: { value: 20 } }, + res, + ); + + assert.calledWith(handler.setValue, thermostatDevice, setpointFeature, 20, true); + }); + + it('should forward manual: false so returning to the schedule does not re-arm the override', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute( + routes, + route, + { params: { feature_selector: 'thermostat-living-room' }, body: { value: 20, manual: false } }, + res, + ); + + assert.calledWith(handler.setValue, thermostatDevice, setpointFeature, 20, false); + }); + + it('should only accept an explicit false, not any falsy value', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute( + routes, + route, + { params: { feature_selector: 'thermostat-living-room' }, body: { value: 20, manual: 0 } }, + res, + ); + + assert.calledWith(handler.setValue, thermostatDevice, setpointFeature, 20, true); + }); + }); + describe('setVariable', () => { const route = 'post /api/v1/service/thermostat/state/:variable_key'; diff --git a/server/test/services/thermostat/lib/thermostat.setValue.test.js b/server/test/services/thermostat/lib/thermostat.setValue.test.js index f5e778b272..8b322aec28 100644 --- a/server/test/services/thermostat/lib/thermostat.setValue.test.js +++ b/server/test/services/thermostat/lib/thermostat.setValue.test.js @@ -153,6 +153,35 @@ describe('thermostat.setValue', () => { assert.calledWith(handler.gladys.variable.setValue, 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL', ''); }); + it('should not touch the manual variables when the write is not manual', async () => { + // The widget writes the scheduled setpoint back through this path when a + // hold ends. Re-arming the override here would leave the database in manual + // mode while every open widget displays the schedule. + const handler = buildHandler(); + + await handler.setValue(scheduledDevice(), deviceFeature, 19, false); + + assert.calledWith(handler.gladys.device.saveState, deviceFeature, 19); + assert.notCalled(handler.gladys.variable.setValue); + assert.notCalled(handler.gladys.event.emit); + }); + + it('should still regulate after a non-manual write', async () => { + const handler = buildHandler(); + + await handler.setValue(scheduledDevice(), deviceFeature, 19, false); + + assert.calledOnce(handler.triggerApplySchedules); + }); + + it('should treat an unspecified write as manual, like a scene does', async () => { + const handler = buildHandler(); + + await handler.setValue(scheduledDevice(), deviceFeature, 19); + + assert.calledWith(handler.gladys.variable.setValue, 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_MODE', 'true'); + }); + it('should write the runtime variables in this service scope', async () => { const handler = buildHandler(); From 2c0c7488ee7567d6afc17095a839e54ae44c1602 Mon Sep 17 00:00:00 2001 From: William Deren Date: Mon, 24 Aug 2026 16:02:24 +0200 Subject: [PATCH 14/29] test(thermostat): cover the remaining defensive branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch coverage sat at 99.90%: five guards were never exercised. They are all defensive rather than dead, so each one gets the case that reaches it instead of being removed. A runtime variable key with no feature segment ("THERMOSTAT_PRESET") passes the shape check — right prefix, right suffix — but the slice between them is empty, so the ownership lookup must refuse it before querying anything. The device list itself can come back empty or featureless: device.get resolves to null on an install with no thermostat, and a device row can be returned without its features. Neither may throw on the way to the refusal. The manual override path resolves the temperature sensor separately from the scheduled one, so it needs its own case for a sensor that is configured but whose feature is gone (renamed, deleted): it must bail out rather than compare against a missing reading. Both routes read req.body defensively. The setpoint one predates this branch — it came in with the drag fix — but neither had a request reaching it with no body at all. Co-Authored-By: Claude Opus 5 --- .../api/thermostat.controller.test.js | 24 +++++++++++ .../lib/thermostat.regulateDevice.test.js | 17 ++++++++ .../lib/thermostat.setVariable.test.js | 40 +++++++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/server/test/services/thermostat/api/thermostat.controller.test.js b/server/test/services/thermostat/api/thermostat.controller.test.js index edf0b09230..756c8b09fb 100644 --- a/server/test/services/thermostat/api/thermostat.controller.test.js +++ b/server/test/services/thermostat/api/thermostat.controller.test.js @@ -326,6 +326,18 @@ describe('thermostat.controller', () => { assert.calledWith(handler.setValue, thermostatDevice, setpointFeature, 20, true); }); + it('should reject a request with no body at all', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute(routes, route, { params: { feature_selector: 'thermostat-living-room' } }, res); + + expect(res.statusCode).to.equal(400); + expect(res.body).to.deep.equal({ error: 'INVALID_VALUE' }); + assert.notCalled(handler.setValue); + }); + it('should forward manual: false so returning to the schedule does not re-arm the override', async () => { const handler = buildHandler(); const routes = ThermostatController(handler); @@ -392,6 +404,18 @@ describe('thermostat.controller', () => { assert.notCalled(handler.setVariable); }); + it('should reject a request with no body at all', async () => { + const handler = buildHandler(); + const routes = ThermostatController(handler); + const res = buildRes(); + + await callRoute(routes, route, { params: { variable_key: 'THERMOSTAT_X_PRESET' } }, res); + + expect(res.statusCode).to.equal(400); + expect(res.body).to.deep.equal({ error: 'INVALID_VALUE' }); + assert.notCalled(handler.setVariable); + }); + it('should reject a key naming a feature this service does not own', async () => { // The shape is right, so only the ownership lookup can catch it: the row // would otherwise be created for a feature that does not exist and never diff --git a/server/test/services/thermostat/lib/thermostat.regulateDevice.test.js b/server/test/services/thermostat/lib/thermostat.regulateDevice.test.js index 4a82002591..32d8daa3cf 100644 --- a/server/test/services/thermostat/lib/thermostat.regulateDevice.test.js +++ b/server/test/services/thermostat/lib/thermostat.regulateDevice.test.js @@ -177,6 +177,23 @@ describe('thermostat.regulateDevice', () => { ...extra, }); + it('should not actuate when the temperature sensor cannot be read', async () => { + // The sensor is configured but its feature is gone (renamed, deleted): the + // manual branch must bail out rather than compare against a missing value. + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ + features: { 'heater-switch': { selector: 'heater-switch', last_value: 1 } }, + variables: manualVariables({ + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL: String(Date.now() + 60000), + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_SETPOINT: JSON.stringify({ setpoint: 22 }), + }), + }); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + assert.notCalled(gladys.device.setValue); + }); + it('should regulate on the manual setpoint while the timer runs', async () => { const mod = load(fullDaySchedule('comfort')); const gladys = buildGladys({ diff --git a/server/test/services/thermostat/lib/thermostat.setVariable.test.js b/server/test/services/thermostat/lib/thermostat.setVariable.test.js index f842538c6a..a2fb13e5ac 100644 --- a/server/test/services/thermostat/lib/thermostat.setVariable.test.js +++ b/server/test/services/thermostat/lib/thermostat.setVariable.test.js @@ -94,6 +94,46 @@ describe('thermostat.setVariable', () => { assert.notCalled(handler.gladys.variable.setValue); }); + it('should refuse a key with no feature segment at all', async () => { + // "THERMOSTAT_PRESET" passes the shape check — right prefix, right suffix — + // but names no feature: the slice between them is empty. + const handler = buildHandler(); + + let error = null; + try { + await handler.setVariable('THERMOSTAT_PRESET', 'eco'); + } catch (e) { + error = e; + } + + expect(error).to.not.equal(null); + expect(error.message).to.contain('Invalid thermostat variable key'); + assert.notCalled(handler.gladys.device.get); + assert.notCalled(handler.gladys.variable.setValue); + }); + + it('should refuse the key when no thermostat exists at all', async () => { + // device.get resolves to null on an empty install, and a device row can be + // returned without its features: neither may throw on the way to the refusal. + const handler = buildHandler(null); + + let error = null; + try { + await handler.setVariable('THERMOSTAT_LIVING_ROOM_PRESET', 'eco'); + } catch (e) { + error = e; + } + + expect(error).to.not.equal(null); + expect(error.message).to.contain('Invalid thermostat variable key'); + }); + + it('should refuse the key when a thermostat carries no features', async () => { + const handler = buildHandler([{ selector: 'thermostat-living-room' }]); + + expect(await handler.getVariable('THERMOSTAT_LIVING_ROOM_PRESET')).to.equal(null); + }); + it('should broadcast PRESET_UPDATED for a preset variable', async () => { const handler = buildHandler(); From 693f6c92a0ac3b13255a0e8d0f81f338631c6911 Mon Sep 17 00:00:00 2001 From: William Deren Date: Mon, 24 Aug 2026 21:03:49 +0200 Subject: [PATCH 15/29] fix(thermostat): expire a permanent manual hold once a schedule is attached A manual hold taken while the device followed no schedule is permanent by design: setValue writes an empty MANUAL_UNTIL, because nothing would otherwise take the setpoint over and a silent revert minutes later would have no countdown banner to announce it. That hold outlived the condition that justified it. Attaching a schedule afterwards does not touch the runtime variables, so the manual branch of regulateDevice kept returning early on a null expiry and the schedule never took the device over -- not in thirty minutes, not the next day. The widget made it worse: its manual banner requires both the flag and an expiry, so an empty MANUAL_UNTIL fell through to the schedule banner, naming the current slot and offering no cancel button while the server regulated indefinitely on a setpoint entered days earlier. Arm the expiry in the regulation loop instead, when a schedule is set and none is stored. The hold still runs a full duration, so the device behaves exactly like one scheduled from the start, and putting the repair in the loop also fixes the devices already stored in that state rather than only future attachments. The broadcast carries the armed expiry: the event fires with the manual flag unchanged, which neither existing widget branch acted on, and the banner would have stayed wrong until a reload. Co-Authored-By: Claude Opus 5 --- .../boxs/thermostat/ThermostatBox.jsx | 9 ++ .../lib/thermostat.applySchedules.js | 23 +++- .../lib/thermostat.regulateDevice.test.js | 111 ++++++++++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) diff --git a/front/src/components/boxs/thermostat/ThermostatBox.jsx b/front/src/components/boxs/thermostat/ThermostatBox.jsx index e53a23210d..8035a51920 100644 --- a/front/src/components/boxs/thermostat/ThermostatBox.jsx +++ b/front/src/components/boxs/thermostat/ThermostatBox.jsx @@ -496,6 +496,15 @@ class ThermostatBox extends Component { this.loadSchedule(); } else if (isManual !== this.state.isManualMode && !this.savingPreset) { this.setState({ isManualMode: isManual }); + } else if (isManual && payload.manualUntil && !this.state.manualUntil) { + // A hold taken with no schedule carries no expiry, so the banner falls back + // to the schedule one — which has no cancel button. The server arms the + // expiry once a schedule is attached and sends it here: adopting it swaps + // the banner back to the manual one, countdown and cancel button included. + const until = parseInt(payload.manualUntil, 10); + if (until > Date.now()) { + this.setState({ manualUntil: until }); + } } }; diff --git a/server/services/thermostat/lib/thermostat.applySchedules.js b/server/services/thermostat/lib/thermostat.applySchedules.js index 645eb61d42..4f688bf003 100644 --- a/server/services/thermostat/lib/thermostat.applySchedules.js +++ b/server/services/thermostat/lib/thermostat.applySchedules.js @@ -292,7 +292,28 @@ async function regulateDevice(gladys, device, dayOfWeek, currentMinutes, service if (manualVal === 'true') { const manualUntilKey = `THERMOSTAT_${featureKey}_MANUAL_UNTIL`; const manualUntilVal = await gladys.variable.getValue(manualUntilKey, serviceId).catch(() => null); - const manualUntil = manualUntilVal ? parseInt(manualUntilVal, 10) : null; + let manualUntil = manualUntilVal ? parseInt(manualUntilVal, 10) : null; + // A hold taken while the device followed no schedule is permanent by design + // (setValue writes an empty expiry). If a schedule is attached afterwards, + // that hold would never expire and the schedule would never take over, while + // the widget — which only renders the manual banner when an expiry is set — + // would display the schedule banner with no way to cancel. Arming the expiry + // here makes the device behave exactly like one scheduled from the start. + if (!manualUntil && config.active_schedule) { + manualUntil = Date.now() + config.manual_duration * 60 * 1000; + await gladys.variable.setValue(manualUntilKey, String(manualUntil), serviceId); + logger.info( + `Thermostat schedule: permanent manual hold on ${selector} now follows a schedule, ` + + `expiry armed until ${new Date(manualUntil).toISOString()}`, + ); + gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.THERMOSTAT.MANUAL_MODE_UPDATED, + // The expiry rides along: an open widget holds `manualUntil: null` for a + // permanent hold, and would otherwise keep rendering the schedule banner + // with no cancel button until it is reloaded. + payload: { key: manualVarKey, value: 'true', manualUntil: String(manualUntil) }, + }); + } if (manualUntil && Date.now() > manualUntil) { logger.info(`Thermostat schedule: manual timer expired for ${selector}, reverting to schedule`); await gladys.variable.setValue(manualVarKey, 'false', serviceId); diff --git a/server/test/services/thermostat/lib/thermostat.regulateDevice.test.js b/server/test/services/thermostat/lib/thermostat.regulateDevice.test.js index 32d8daa3cf..164d908171 100644 --- a/server/test/services/thermostat/lib/thermostat.regulateDevice.test.js +++ b/server/test/services/thermostat/lib/thermostat.regulateDevice.test.js @@ -267,6 +267,117 @@ describe('thermostat.regulateDevice', () => { assert.notCalled(gladys.device.setValue); }); + it('should arm the expiry when a schedule is attached after a permanent hold', async () => { + // Hold taken with no schedule: setValue wrote an empty MANUAL_UNTIL. Once a + // schedule is attached, that hold must stop being permanent, otherwise the + // schedule never takes over. + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ + features: standardFeatures({ temp: 15 }), + variables: manualVariables({ + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL: '', + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_SETPOINT: JSON.stringify({ setpoint: 22 }), + }), + }); + const before = Date.now(); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + const call = gladys.variable.setValue + .getCalls() + .find((c) => c.args[0] === 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL'); + expect(call).to.not.equal(undefined); + const armed = parseInt(call.args[1], 10); + // 30 minutes is the shared default, the device configures no duration here. + expect(armed).to.be.at.least(before + 30 * 60 * 1000); + expect(armed).to.be.at.most(Date.now() + 30 * 60 * 1000); + // The hold itself still runs: this pass regulates on the manual setpoint. + assert.neverCalledWith(gladys.variable.setValue, 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_MODE', 'false'); + }); + + it('should use the duration configured on the device when arming that expiry', async () => { + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ + features: standardFeatures({ temp: 15 }), + variables: manualVariables({ + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL: '', + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_SETPOINT: JSON.stringify({ setpoint: 22 }), + }), + }); + const before = Date.now(); + + await regulate(mod, gladys, { + features: [setpointFeature()], + params: baseParams({ THERMOSTAT_MANUAL_DURATION: '120' }), + }); + + const call = gladys.variable.setValue + .getCalls() + .find((c) => c.args[0] === 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL'); + expect(parseInt(call.args[1], 10)).to.be.at.least(before + 120 * 60 * 1000); + }); + + it('should carry the armed expiry to open dashboards', async () => { + // The widget renders the manual banner only when it holds an expiry; a + // permanent hold leaves it on the schedule banner, which has no cancel + // button. The broadcast carries the expiry so it can swap back. + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ + features: standardFeatures({ temp: 15 }), + variables: manualVariables({ + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL: '', + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_SETPOINT: JSON.stringify({ setpoint: 22 }), + }), + }); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + const emitted = gladys.event.emit + .getCalls() + .find((c) => c.args[1] && c.args[1].payload && c.args[1].payload.manualUntil); + expect(emitted).to.not.equal(undefined); + expect(emitted.args[1].payload.key).to.equal('THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_MODE'); + expect(emitted.args[1].payload.value).to.equal('true'); + expect(parseInt(emitted.args[1].payload.manualUntil, 10)).to.be.above(Date.now()); + }); + + it('should leave a permanent hold alone while no schedule is attached', async () => { + // Without a schedule the hold is permanent by design: nothing would take + // the setpoint over, and the widget offers the preset bar to leave it. + const mod = load(fullDaySchedule('comfort')); + const gladys = buildGladys({ + features: standardFeatures({ temp: 15 }), + variables: manualVariables({ + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL: '', + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_SETPOINT: JSON.stringify({ setpoint: 22 }), + }), + }); + + await regulate(mod, gladys, { + features: [setpointFeature()], + params: baseParams({ THERMOSTAT_ACTIVE_SCHEDULE: '' }), + }); + + assert.neverCalledWith(gladys.variable.setValue, 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL'); + assert.neverCalledWith(gladys.variable.setValue, 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_MODE', 'false'); + }); + + it('should not re-arm an expiry that is already set', async () => { + const mod = load(fullDaySchedule('comfort')); + const until = String(Date.now() + 60000); + const gladys = buildGladys({ + features: standardFeatures({ temp: 15 }), + variables: manualVariables({ + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL: until, + THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_SETPOINT: JSON.stringify({ setpoint: 22 }), + }), + }); + + await regulate(mod, gladys, { features: [setpointFeature()], params: baseParams() }); + + assert.neverCalledWith(gladys.variable.setValue, 'THERMOSTAT_THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL'); + }); + it('should revert to the schedule once the manual timer expired', async () => { const mod = load(fullDaySchedule('comfort')); const gladys = buildGladys({ From 90a11ffe001f116251ec8f764e7b62c28201d2a4 Mon Sep 17 00:00:00 2001 From: William Deren Date: Mon, 24 Aug 2026 21:04:09 +0200 Subject: [PATCH 16/29] perf(thermostat): cache the runtime feature keys behind the ownership check resolveRuntimeVariableKey guards every getVariable/setVariable call, and rebuilt the set of features owned by this service on each one with a device query. A widget fires four or five of those on mount. Cache the feature keys the way onWindowOpen already caches the window selectors. Both sets derive from this service's devices and go stale at the same three moments, so invalidateWindowCache becomes invalidateDeviceCaches and drops both -- the create, update and delete hooks were already wired to it and needed no new call site. The shape check still runs first, so a key outside the runtime namespace costs no query at all. Co-Authored-By: Claude Opus 5 --- server/services/thermostat/lib/index.js | 11 +-- .../thermostat/lib/thermostat.createDevice.js | 6 +- .../thermostat/lib/thermostat.onWindowOpen.js | 16 +++-- .../thermostat/lib/thermostat.postDelete.js | 2 +- .../thermostat/lib/thermostat.setVariable.js | 32 +++++++-- .../thermostat/lib/thermostat.devices.test.js | 8 +-- .../lib/thermostat.onWindowOpen.test.js | 14 +++- .../lib/thermostat.setVariable.test.js | 67 ++++++++++++++++++- 8 files changed, 129 insertions(+), 27 deletions(-) diff --git a/server/services/thermostat/lib/index.js b/server/services/thermostat/lib/index.js index 3dbf928e83..61f505d41c 100644 --- a/server/services/thermostat/lib/index.js +++ b/server/services/thermostat/lib/index.js @@ -9,7 +9,7 @@ const { applySchedules } = require('./thermostat.applySchedules'); const { onDeviceNewState, getWindowSelectors, - invalidateWindowCache, + invalidateDeviceCaches, postUpdate, } = require('./thermostat.onWindowOpen'); const { setValue } = require('./thermostat.setValue'); @@ -17,6 +17,7 @@ const { postDelete } = require('./thermostat.postDelete'); const { setVariable, getVariable, + getFeatureKeys, resolveRuntimeVariableKey, broadcastConfigUpdated, triggerApplySchedules, @@ -26,9 +27,10 @@ const ThermostatHandler = function ThermostatHandler(gladys, serviceId) { this.gladys = gladys; this.serviceId = serviceId; this.applyTimer = null; - // Window-sensor selectors, rebuilt lazily and dropped whenever a thermostat - // device is created or deleted. + // Derived from this service's devices, rebuilt lazily and dropped whenever a + // thermostat device is created, updated or deleted. this.windowSelectorsCache = null; + this.featureKeysCache = null; }; ThermostatHandler.prototype.createDevice = createDevice; @@ -41,12 +43,13 @@ ThermostatHandler.prototype.detachSchedule = detachSchedule; ThermostatHandler.prototype.applySchedules = applySchedules; ThermostatHandler.prototype.onDeviceNewState = onDeviceNewState; ThermostatHandler.prototype.getWindowSelectors = getWindowSelectors; -ThermostatHandler.prototype.invalidateWindowCache = invalidateWindowCache; +ThermostatHandler.prototype.invalidateDeviceCaches = invalidateDeviceCaches; ThermostatHandler.prototype.postUpdate = postUpdate; ThermostatHandler.prototype.setValue = setValue; ThermostatHandler.prototype.postDelete = postDelete; ThermostatHandler.prototype.setVariable = setVariable; ThermostatHandler.prototype.getVariable = getVariable; +ThermostatHandler.prototype.getFeatureKeys = getFeatureKeys; ThermostatHandler.prototype.resolveRuntimeVariableKey = resolveRuntimeVariableKey; ThermostatHandler.prototype.broadcastConfigUpdated = broadcastConfigUpdated; ThermostatHandler.prototype.triggerApplySchedules = triggerApplySchedules; diff --git a/server/services/thermostat/lib/thermostat.createDevice.js b/server/services/thermostat/lib/thermostat.createDevice.js index c0fa57465b..59f0126318 100644 --- a/server/services/thermostat/lib/thermostat.createDevice.js +++ b/server/services/thermostat/lib/thermostat.createDevice.js @@ -63,9 +63,9 @@ async function createDevice(device) { params, service_id: this.serviceId, }); - // The window sensor may have changed: drop the cached selectors so the next - // NEW_STATE event rebuilds them. - this.invalidateWindowCache(); + // The window sensor and the feature set may have changed: drop the caches + // derived from them so the next read rebuilds them. + this.invalidateDeviceCaches(); return createdDevice; } diff --git a/server/services/thermostat/lib/thermostat.onWindowOpen.js b/server/services/thermostat/lib/thermostat.onWindowOpen.js index 1e38c13ea5..357b01ccb8 100644 --- a/server/services/thermostat/lib/thermostat.onWindowOpen.js +++ b/server/services/thermostat/lib/thermostat.onWindowOpen.js @@ -3,15 +3,17 @@ const { getThermostatFeature } = require('./thermostat.applySchedules'); const { buildParamsConfig, getFeatureBySelector } = require('./thermostat.deviceConfig'); /** - * @description Invalidate the cached window-sensor selectors. Called whenever a - * thermostat device is created, updated or deleted, so the next event rebuilds - * the map. + * @description Invalidate the caches derived from this service's devices: the + * window-sensor selectors and the runtime feature keys. Called whenever a + * thermostat device is created, updated or deleted — the only moments where the + * set of owned features can change — so the next read rebuilds them. * @returns {undefined} * @example - * thermostatHandler.invalidateWindowCache(); + * thermostatHandler.invalidateDeviceCaches(); */ -function invalidateWindowCache() { +function invalidateDeviceCaches() { this.windowSelectorsCache = null; + this.featureKeysCache = null; } /** @@ -26,7 +28,7 @@ function invalidateWindowCache() { * thermostatHandler.postUpdate(); */ function postUpdate() { - this.invalidateWindowCache(); + this.invalidateDeviceCaches(); } /** @@ -124,4 +126,4 @@ async function onDeviceNewState(event) { } } -module.exports = { onDeviceNewState, getWindowSelectors, invalidateWindowCache, postUpdate }; +module.exports = { onDeviceNewState, getWindowSelectors, invalidateDeviceCaches, postUpdate }; diff --git a/server/services/thermostat/lib/thermostat.postDelete.js b/server/services/thermostat/lib/thermostat.postDelete.js index 2dee9f77bc..5d52237045 100644 --- a/server/services/thermostat/lib/thermostat.postDelete.js +++ b/server/services/thermostat/lib/thermostat.postDelete.js @@ -16,7 +16,7 @@ const VARIABLE_SUFFIXES = RUNTIME_SUFFIXES; * await thermostatHandler.postDelete(device); */ async function postDelete(device) { - this.invalidateWindowCache(); + this.invalidateDeviceCaches(); const features = (device && device.features) || []; await Promise.all( features.map(async (feature) => { diff --git a/server/services/thermostat/lib/thermostat.setVariable.js b/server/services/thermostat/lib/thermostat.setVariable.js index a9ed519920..bb62597cb4 100644 --- a/server/services/thermostat/lib/thermostat.setVariable.js +++ b/server/services/thermostat/lib/thermostat.setVariable.js @@ -35,6 +35,31 @@ function featureKeyFromSelector(selector) { return selector.toUpperCase().replace(/-/g, '_'); } +/** + * @description The runtime feature keys owned by this service, as the middle + * segment of their variable keys. Every getVariable/setVariable call has to + * check ownership, and a widget fires four or five of them on mount: without + * this cache each one is a device query. Dropped by `invalidateDeviceCaches` + * whenever a thermostat is created, updated or deleted. + * @returns {Promise>} Owned feature keys. + * @example + * const keys = await thermostatHandler.getFeatureKeys(); + */ +async function getFeatureKeys() { + if (this.featureKeysCache) { + return this.featureKeysCache; + } + const devices = await this.gladys.device.get({ service: 'thermostat' }); + const featureKeys = new Set(); + (devices || []).forEach((device) => { + (device.features || []).forEach((feature) => { + featureKeys.add(featureKeyFromSelector(feature.selector)); + }); + }); + this.featureKeysCache = featureKeys; + return featureKeys; +} + /** * @description Check that a runtime key names a feature owned by this service. * The prefix and suffix alone are not enough: THERMOSTAT_ANYTHING_PRESET would @@ -54,10 +79,8 @@ async function resolveRuntimeVariableKey(variableKey) { if (featureKey.length === 0) { return false; } - const devices = await this.gladys.device.get({ service: 'thermostat' }); - return (devices || []).some((device) => - (device.features || []).some((feature) => featureKeyFromSelector(feature.selector) === featureKey), - ); + const featureKeys = await getFeatureKeys.call(this); + return featureKeys.has(featureKey); } /** @@ -154,6 +177,7 @@ function triggerApplySchedules() { module.exports = { setVariable, getVariable, + getFeatureKeys, resolveRuntimeVariableKey, broadcastConfigUpdated, triggerApplySchedules, diff --git a/server/test/services/thermostat/lib/thermostat.devices.test.js b/server/test/services/thermostat/lib/thermostat.devices.test.js index b59af6f568..20a3fd5e37 100644 --- a/server/test/services/thermostat/lib/thermostat.devices.test.js +++ b/server/test/services/thermostat/lib/thermostat.devices.test.js @@ -62,7 +62,7 @@ describe('thermostat.createDevice', () => { const buildHandler = () => ({ gladys: { device: { create: fake((device) => Promise.resolve(device)) } }, serviceId: 'service-id', - invalidateWindowCache: fake.returns(null), + invalidateDeviceCaches: fake.returns(null), createDevice, }); @@ -174,7 +174,7 @@ describe('thermostat.createDevice', () => { await handler.createDevice({ name: 'Salon', features: [setpointFeature] }); - assert.calledOnce(handler.invalidateWindowCache); + assert.calledOnce(handler.invalidateDeviceCaches); }); }); @@ -182,7 +182,7 @@ describe('thermostat.postDelete', () => { const buildHandler = (destroy) => ({ gladys: { variable: { destroy } }, serviceId: 'service-id', - invalidateWindowCache: fake.returns(null), + invalidateDeviceCaches: fake.returns(null), postDelete, }); @@ -243,7 +243,7 @@ describe('thermostat.createDevice - defensive paths', () => { const handler = { gladys: { device: { create: fake.resolves(null) } }, serviceId: 'service-id', - invalidateWindowCache: fake.returns(null), + invalidateDeviceCaches: fake.returns(null), createDevice, }; diff --git a/server/test/services/thermostat/lib/thermostat.onWindowOpen.test.js b/server/test/services/thermostat/lib/thermostat.onWindowOpen.test.js index e2dbc80d7d..ee31442486 100644 --- a/server/test/services/thermostat/lib/thermostat.onWindowOpen.test.js +++ b/server/test/services/thermostat/lib/thermostat.onWindowOpen.test.js @@ -328,13 +328,21 @@ describe('thermostat.onDeviceNewState - window selector cache', () => { it('should rebuild the cache once it has been invalidated', async () => { const mod = loadModule(); const { gladys } = buildGladys(); - const handler = { gladys, windowSelectorsCache: null, invalidateWindowCache: mod.invalidateWindowCache }; + const handler = { + gladys, + windowSelectorsCache: null, + featureKeysCache: new Set(['LIVING_ROOM']), + invalidateDeviceCaches: mod.invalidateDeviceCaches, + }; await mod.onDeviceNewState.call(handler, { device_feature: 'some-other-sensor', last_value: 0 }); expect(gladys.device.get.callCount).to.equal(1); - handler.invalidateWindowCache(); + handler.invalidateDeviceCaches(); expect(handler.windowSelectorsCache).to.equal(null); + // The same invalidation covers the runtime feature keys: both are derived + // from this service's devices and go stale at the same moments. + expect(handler.featureKeysCache).to.equal(null); await mod.onDeviceNewState.call(handler, { device_feature: 'some-other-sensor', last_value: 0 }); expect(gladys.device.get.callCount).to.equal(2); @@ -349,7 +357,7 @@ describe('thermostat.onDeviceNewState - window selector cache', () => { const handler = { gladys, windowSelectorsCache: null, - invalidateWindowCache: mod.invalidateWindowCache, + invalidateDeviceCaches: mod.invalidateDeviceCaches, postUpdate: mod.postUpdate, }; diff --git a/server/test/services/thermostat/lib/thermostat.setVariable.test.js b/server/test/services/thermostat/lib/thermostat.setVariable.test.js index a2fb13e5ac..f960f4b61d 100644 --- a/server/test/services/thermostat/lib/thermostat.setVariable.test.js +++ b/server/test/services/thermostat/lib/thermostat.setVariable.test.js @@ -19,7 +19,14 @@ const load = () => // given a thermostat owning it: a key whose middle segment names no feature of // this service is refused. const buildHandler = (devices = [{ features: [{ selector: 'living-room' }] }]) => { - const { setVariable, getVariable, resolveRuntimeVariableKey, broadcastConfigUpdated, triggerApplySchedules } = load(); + const { + setVariable, + getVariable, + getFeatureKeys, + resolveRuntimeVariableKey, + broadcastConfigUpdated, + triggerApplySchedules, + } = load(); const handler = { gladys: { variable: { setValue: fake.resolves({ value: 'saved' }), getValue: fake.resolves('comfort') }, @@ -28,8 +35,10 @@ const buildHandler = (devices = [{ features: [{ selector: 'living-room' }] }]) = }, serviceId: 'service-id', applySchedules: fake.resolves(null), + featureKeysCache: null, setVariable, getVariable, + getFeatureKeys, resolveRuntimeVariableKey, broadcastConfigUpdated, triggerApplySchedules, @@ -162,6 +171,62 @@ describe('thermostat.setVariable', () => { }); }); +describe('thermostat.getFeatureKeys cache', () => { + it('should query the devices once across several ownership checks', async () => { + // A widget fires four or five getVariable/setVariable calls on mount; each + // one used to be a device query. + const handler = buildHandler(); + + await handler.getVariable('THERMOSTAT_LIVING_ROOM_PRESET'); + await handler.getVariable('THERMOSTAT_LIVING_ROOM_MANUAL_MODE'); + await handler.getVariable('THERMOSTAT_LIVING_ROOM_MANUAL_UNTIL'); + await handler.setVariable('THERMOSTAT_LIVING_ROOM_MANUAL_SETPOINT', '{"setpoint":21}'); + + expect(handler.gladys.device.get.callCount).to.equal(1); + }); + + it('should rebuild the cache once it has been invalidated', async () => { + const handler = buildHandler(); + + await handler.getVariable('THERMOSTAT_LIVING_ROOM_PRESET'); + expect(handler.gladys.device.get.callCount).to.equal(1); + + handler.featureKeysCache = null; + + await handler.getVariable('THERMOSTAT_LIVING_ROOM_PRESET'); + expect(handler.gladys.device.get.callCount).to.equal(2); + }); + + it('should cache a refusal too, without re-querying', async () => { + const handler = buildHandler([{ features: [{ selector: 'kitchen' }] }]); + + expect(await handler.getVariable('THERMOSTAT_LIVING_ROOM_PRESET')).to.equal(null); + expect(await handler.getVariable('THERMOSTAT_LIVING_ROOM_PRESET')).to.equal(null); + + expect(handler.gladys.device.get.callCount).to.equal(1); + }); + + it('should not query the devices at all for a key outside the namespace', async () => { + // The shape check comes first, so a key that can never be owned costs nothing. + const handler = buildHandler(); + + expect(await handler.getVariable('SOME_OTHER_VARIABLE')).to.equal(null); + + assert.notCalled(handler.gladys.device.get); + }); + + it('should collect the keys of every feature of every thermostat', async () => { + const handler = buildHandler([ + { features: [{ selector: 'living-room' }, { selector: 'living-room-humidity' }] }, + { features: [{ selector: 'kitchen' }] }, + ]); + + const keys = await handler.getFeatureKeys(); + + expect([...keys].sort()).to.deep.equal(['KITCHEN', 'LIVING_ROOM', 'LIVING_ROOM_HUMIDITY']); + }); +}); + describe('thermostat.broadcastConfigUpdated', () => { it('should tell the dashboards to reload, without carrying the config itself', async () => { const handler = buildHandler(); From 4de1637025c3cbcb0a19b4cb0cc1ed24f0eabb3f Mon Sep 17 00:00:00 2001 From: William Deren Date: Mon, 24 Aug 2026 22:16:25 +0200 Subject: [PATCH 17/29] fix(thermostat): keep overnight slots when copying a day onto other days Filling a Monday and copying it onto the rest of the week is the nominal way to build a heating schedule, and it produced a schedule the editor then refused to save. A slot crossing midnight is stored as two rows, because a row belongs to exactly one day: 22:30 -> 00:00 on Monday, plus 00:00 -> 06:30 on Tuesday. The copy worked from the source day's rows alone, so it dropped the morning half -- and, Tuesday being a target itself, overwrote the one already there. Every day ended up with a hole from midnight to 06:30, under bars that visibly started at 06:30 and a Night slot the user had entered. Pair the two rows back before copying. The `overflow-` key prefix cannot identify them: keys are render-only handles, stripped on save and regenerated on load, so a reopened schedule has none. readDayAsEntered recovers the pair from the geometry instead -- a slot ending at midnight, and one starting at midnight the next day on the same preset -- and copyDayOntoDays lays the source back down through applySlotToDay, so each target spills onto the day after it the way the editor would have. Two ordering details the fix depends on: every target is cleared before any is filled, otherwise a target wipes the overflow the previous one just wrote onto it; and the source is replayed onto itself when the day after it is a target, since clearing that target also dropped the overflow the source spills there. Co-Authored-By: Claude Opus 5 --- server/test/utils/thermostatSchedule.test.js | 189 +++++++++++++++++++ server/utils/thermostatSchedule.js | 104 ++++++++++ 2 files changed, 293 insertions(+) diff --git a/server/test/utils/thermostatSchedule.test.js b/server/test/utils/thermostatSchedule.test.js index 915e46d717..af9de496d3 100644 --- a/server/test/utils/thermostatSchedule.test.js +++ b/server/test/utils/thermostatSchedule.test.js @@ -2,6 +2,8 @@ const { expect } = require('chai'); const { applySlotToDay, mergeIntoSlots, + readDayAsEntered, + copyDayOntoDays, timeToMinutes, minutesToTime, parseEnd, @@ -460,3 +462,190 @@ describe('thermostatSchedule.applySlotToDay - end of day sorting', () => { expect(fixedSlots.filter((s) => s.key === 'split-b')).to.have.lengthOf(1); }); }); + +// Builds a schedule the way the editor does: every entry goes through +// applySlotToDay/mergeIntoSlots, so an overnight entry is stored split in two. +const buildSchedule = (entries) => { + let slots = []; + let counter = 0; + entries.forEach(([day, startTime, endTime, preset]) => { + const start = timeToMinutes(startTime); + let end = timeToMinutes(endTime); + if (end <= start) { + end += DAY_MINUTES; + } + const existing = slots.filter((s) => s.day_of_week === day); + counter += 1; + const { fixedSlots, overflowSlot } = applySlotToDay(existing, day, start, end, preset, `k${counter}`, null); + const tagged = fixedSlots.map((s) => ({ ...s, day_of_week: day })); + slots = mergeIntoSlots(slots, day, tagged, overflowSlot); + }); + return slots; +}; + +const dayOf = (slots, day) => + slots + .filter((s) => s.day_of_week === day) + .sort((a, b) => timeToMinutes(a.start_time) - timeToMinutes(b.start_time)) + .map((s) => `${s.start_time}->${s.end_time} ${s.preset}`); + +// Same coverage rule the editor enforces before saving. +const daysWithGaps = (slots) => { + const gaps = []; + [0, 1, 2, 3, 4, 5, 6].forEach((day) => { + const daySlots = slots + .filter((s) => s.day_of_week === day) + .map((s) => ({ start: timeToMinutes(s.start_time), end: timeToMinutes(s.end_time) || DAY_MINUTES })) + .sort((a, b) => a.start - b.start); + if (daySlots.length === 0) { + gaps.push(day); + return; + } + let covered = 0; + let holed = false; + daySlots.forEach((s) => { + if (!holed && s.start > covered) { + holed = true; + } + covered = Math.max(covered, s.end); + }); + if (holed || covered < DAY_MINUTES) { + gaps.push(day); + } + }); + return gaps; +}; + +let keyCounter = 0; +const makeKey = () => { + keyCounter += 1; + return `copy-${keyCounter}`; +}; + +describe('thermostatSchedule.readDayAsEntered', () => { + it('should re-join a night crossing midnight with its piece on the next day', () => { + const slots = buildSchedule([[0, '22:30', '06:30', 'night']]); + + const read = readDayAsEntered(slots, 0); + + expect(read).to.have.lengthOf(1); + expect(read[0].start_time).to.equal('22:30'); + expect(read[0].end_time).to.equal('06:30'); + expect(read[0].overnight).to.equal(true); + }); + + it('should leave a slot merely ending at midnight alone', () => { + const slots = buildSchedule([[0, '18:00', '00:00', 'eco']]); + + const read = readDayAsEntered(slots, 0); + + expect(read[0].end_time).to.equal('00:00'); + expect(read[0].overnight).to.equal(undefined); + }); + + it('should not pair an evening slot with a next-day slot of another preset', () => { + // 22:30->00:00 night then 00:00->06:00 eco is two deliberate slots, not a split one. + const slots = buildSchedule([ + [0, '22:30', '00:00', 'night'], + [1, '00:00', '06:00', 'eco'], + ]); + + const read = readDayAsEntered(slots, 0); + + expect(read.find((s) => s.start_time === '22:30').overnight).to.equal(undefined); + }); + + it('should wrap from Sunday to Monday', () => { + const slots = buildSchedule([[6, '23:00', '05:00', 'night']]); + + const read = readDayAsEntered(slots, 6); + + expect(read[0].end_time).to.equal('05:00'); + expect(read[0].overnight).to.equal(true); + }); +}); + +describe('thermostatSchedule.copyDayOntoDays', () => { + it('should copy a full day with an overnight slot onto every other day without leaving a gap', () => { + // The nominal heating week: a Monday filled in, then copied onto all days. + // The night is stored as 22:30->00:00 on Monday plus 00:00->06:30 on Tuesday, + // so copying Monday's rows alone used to drop every morning. + const slots = buildSchedule([ + [0, '06:30', '08:30', 'comfort'], + [0, '08:30', '17:00', 'eco'], + [0, '17:00', '22:30', 'comfort'], + [0, '22:30', '06:30', 'night'], + ]); + + const copied = copyDayOntoDays(slots, 0, [1, 2, 3, 4, 5, 6], makeKey); + + expect(daysWithGaps(copied)).to.deep.equal([]); + [0, 1, 2, 3, 4, 5, 6].forEach((day) => { + expect(dayOf(copied, day)).to.deep.equal([ + '00:00->06:30 night', + '06:30->08:30 comfort', + '08:30->17:00 eco', + '17:00->22:30 comfort', + '22:30->00:00 night', + ]); + }); + }); + + it('should push the overflow onto the day after each target', () => { + const slots = buildSchedule([ + [0, '06:30', '22:30', 'comfort'], + [0, '22:30', '06:30', 'night'], + ]); + + const copied = copyDayOntoDays(slots, 0, [1], makeKey); + + // Tuesday now carries the night itself, and spills onto Wednesday. + expect(dayOf(copied, 1)).to.deep.equal(['00:00->06:30 night', '06:30->22:30 comfort', '22:30->00:00 night']); + expect(dayOf(copied, 2)).to.deep.equal(['00:00->06:30 night']); + }); + + it('should wrap the overflow of the last day back onto the first', () => { + const slots = buildSchedule([ + [0, '06:30', '22:30', 'comfort'], + [0, '22:30', '06:30', 'night'], + ]); + + const copied = copyDayOntoDays(slots, 0, [6], makeKey); + + // Sunday's night spills onto Monday, which keeps its own slots otherwise. + expect(dayOf(copied, 0)).to.contain('00:00->06:30 night'); + expect(dayOf(copied, 6)).to.contain('22:30->00:00 night'); + }); + + it('should copy a day with no overnight slot unchanged', () => { + const slots = buildSchedule([ + [0, '00:00', '12:00', 'eco'], + [0, '12:00', '00:00', 'comfort'], + ]); + + const copied = copyDayOntoDays(slots, 0, [3], makeKey); + + expect(dayOf(copied, 3)).to.deep.equal(['00:00->12:00 eco', '12:00->00:00 comfort']); + // Nothing spilled onto Thursday. + expect(dayOf(copied, 4)).to.deep.equal([]); + }); + + it('should replace the slots already on a target day', () => { + const slots = buildSchedule([ + [0, '08:00', '00:00', 'comfort'], + [2, '00:00', '00:00', 'frost'], + ]); + + const copied = copyDayOntoDays(slots, 0, [2], makeKey); + + expect(dayOf(copied, 2)).to.deep.equal(['08:00->00:00 comfort']); + }); + + it('should leave the schedule untouched when there is no target', () => { + const slots = buildSchedule([[0, '08:00', '18:00', 'comfort']]); + + const copied = copyDayOntoDays(slots, 0, [], makeKey); + + expect(dayOf(copied, 0)).to.deep.equal(['08:00->18:00 comfort']); + }); +}); diff --git a/server/utils/thermostatSchedule.js b/server/utils/thermostatSchedule.js index 29cb0e8306..4b8a4ba59b 100644 --- a/server/utils/thermostatSchedule.js +++ b/server/utils/thermostatSchedule.js @@ -151,6 +151,108 @@ const mergeIntoSlots = (allSlots, dayOfWeek, taggedFixed, overflowSlot) => { return [...otherDays, ...taggedFixed, ...nextDayKept, { ...overflowSlot, day_of_week: nextDay }]; }; +/** + * @description Read a day's slots as the user entered them, re-joining a slot that + * crosses midnight with the 00:00 piece it left on the next day. + * + * `applySlotToDay` stores an overnight slot as two rows — 22:30→00:00 on the day + * itself, 00:00→06:30 on the next one — because a row belongs to exactly one day. + * That split is invisible to the regulation loop, which reads yesterday's slots + * too, but any operation working on "this day" sees only half of it. Copying a + * day from its rows alone therefore drops the morning half and, when the next day + * is itself a copy target, overwrites it. + * + * The pairing cannot use the `overflow-` key prefix: keys are render-only handles, + * stripped on save and regenerated on load, so a reopened schedule has none. It is + * recovered from the geometry instead — a slot ending at midnight, and a slot + * starting at midnight on the next day with the same preset. + * @param {Array} allSlots - All slots across all days. + * @param {number} dayOfWeek - The day to read (0=Monday … 6=Sunday). + * @returns {Array} The day's slots, overnight ones carrying an `end_time` past 1440. + * @example + * // 22:30→00:00 on day 0 plus 00:00→06:30 on day 1 reads back as one 22:30→06:30 slot + * readDayAsEntered(slots, 0); + */ +const readDayAsEntered = (allSlots, dayOfWeek) => { + const nextDay = (dayOfWeek + 1) % 7; + const daySlots = allSlots.filter((s) => s.day_of_week === dayOfWeek); + const nextDaySlots = allSlots.filter((s) => s.day_of_week === nextDay); + return daySlots.map((slot) => { + const endsAtMidnight = timeToMinutes(slot.end_time) === 0; + if (!endsAtMidnight) { + return slot; + } + // A slot ending at midnight only overflows when the next day opens at + // midnight on the same preset. Anything else is a plain evening slot. + const overflow = nextDaySlots.find( + (s) => timeToMinutes(s.start_time) === 0 && s.preset === slot.preset && timeToMinutes(s.end_time) !== 0, + ); + if (!overflow) { + return slot; + } + return { ...slot, end_time: overflow.end_time, overnight: true }; + }); +}; + +/** + * @description Copy one day's slots onto other days, preserving overnight slots. + * Each target is rebuilt from the source read as entered, so a night crossing + * midnight lands on the target as the same pair of rows the editor would have + * produced there: the evening piece on the target, the morning piece on the day + * after it. + * + * Targets are applied one after another through `applySlotToDay`/`mergeIntoSlots` + * rather than assigned wholesale, so that when consecutive days are copied the + * overflow written onto a day is trimmed by that day's own slots instead of + * silently surviving or clobbering them. + * @param {Array} allSlots - All slots across all days. + * @param {number} sourceDay - Day to copy from. + * @param {Array} targetDays - Days to copy onto. + * @param {Function} makeKey - Returns a fresh unique key for a created slot. + * @returns {Array} The updated slots array. + * @example + * copyDayOntoDays(slots, 0, [1, 2, 3], () => Math.random()); + */ +const copyDayOntoDays = (allSlots, sourceDay, targetDays, makeKey) => { + const sourceSlots = readDayAsEntered(allSlots, sourceDay) + .slice() + .sort((a, b) => { + return timeToMinutes(a.start_time) - timeToMinutes(b.start_time); + }); + // The source is read once, up front: applying a target may rewrite the source + // day itself (copying Monday onto Sunday overflows back onto Monday). + // + // Every target is cleared before any is filled. Clearing them one at a time + // would wipe the overflow the previous target just wrote onto this one, which + // is the very bug this function exists to fix. + let result = allSlots.filter((s) => !targetDays.includes(s.day_of_week)); + // Clearing the targets also dropped the overflow the source itself spills onto + // the day after it, when that day is a target. The source keeps its own rows, + // so replaying it onto itself is what puts that piece back. + const daysToFill = targetDays.includes((sourceDay + 1) % 7) ? [...targetDays, sourceDay] : targetDays; + daysToFill.forEach((targetDay) => { + sourceSlots.forEach((slot) => { + const start = timeToMinutes(slot.start_time); + const rawEnd = timeToMinutes(slot.end_time) || DAY_MINUTES; + // An overnight slot was re-joined above: its end belongs to the next day. + const end = slot.overnight ? rawEnd + DAY_MINUTES : rawEnd; + const existing = result.filter((s) => s.day_of_week === targetDay); + const { fixedSlots, overflowSlot } = applySlotToDay( + existing, + targetDay, + start, + end, + slot.preset, + makeKey(), + null, + ); + const taggedFixed = fixedSlots.map((s) => ({ ...s, day_of_week: targetDay })); + result = mergeIntoSlots(result, targetDay, taggedFixed, overflowSlot); + }); + }); + return result; +}; + /** * @description Parse an end time string, treating 00:00 as end of day (1440 minutes). * @param {string} timeStr - Time string in HH:MM format. @@ -271,6 +373,8 @@ const findMatchingPreset = (todaySlots, yesterdaySlots, currentMinutes) => { module.exports = { applySlotToDay, mergeIntoSlots, + readDayAsEntered, + copyDayOntoDays, timeToMinutes, minutesToTime, parseEnd, From bde98010db350ae446742b1c17e97aa112463870 Mon Sep 17 00:00:00 2001 From: William Deren Date: Mon, 24 Aug 2026 22:16:38 +0200 Subject: [PATCH 18/29] fix(thermostat): make the schedule editor readable and stop it blocking saves Three things the editor got wrong about its own model, all found by testing the real thing. Gaps no longer block saving. The regulation loop handles them -- it falls back on the current preset, which the spec documents and the server's Joi schema accepts without a word -- so refusing to save a daytime-only schedule (offices: 08:00 -> 18:00, the rest on a preset chosen by hand) was the editor being stricter than the system behind it. It is a warning now, computed on render so it follows the typing instead of appearing on a click, and an empty schedule shows none: it is one being started, not one with holes. That warning also says what is missing -- "Tuesday: 00:00 -> 06:30" -- rather than listing the days and leaving the user to find out. The old message rendered them as badge-light on alert-danger, white on pale salmon, near unreadable. A night crossing midnight now reads back as 22:30 -> 06:30 +1d instead of a truncated 22:30 -> 00:00, so the list shows what was typed. The bars still draw the stored rows: they represent what each day actually covers. Removing or editing that slot had to learn about the pair too, or it left the morning half orphaned on the next day -- already true before, but invisible while the two rows showed separately. Also: the time inputs size to their content so a 12-hour locale keeps its AM/PM indicator, and a prefilled 00:00 -> 00:00 says that it means the whole day. Co-Authored-By: Claude Opus 5 --- front/src/config/i18n/de.json | 4 +- front/src/config/i18n/en.json | 4 +- front/src/config/i18n/fr.json | 6 +- .../schedule-page/ScheduleEditor.jsx | 238 +++++++++++------- .../all/thermostat/schedule-page/style.css | 51 +++- 5 files changed, 203 insertions(+), 100 deletions(-) diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index 1983cfc5c1..4f03b2c6b7 100644 --- a/front/src/config/i18n/de.json +++ b/front/src/config/i18n/de.json @@ -3389,7 +3389,9 @@ "confirmYes": "Ja", "confirmNo": "Nein", "saveError": "Beim Speichern ist ein Fehler aufgetreten.", - "gapError": "An manchen Tagen gibt es nicht abgedeckte Zeitfenster (Lücken):", + "gapWarning": "Einige Tage sind nicht vollständig abgedeckt. Das Thermostat bleibt in diesen Zeiträumen auf seinem aktuellen Preset:", + "nextDay": "+1T", + "fullDayHint": "00:00 → 00:00 deckt den ganzen Tag ab.", "duplicateButton": "Duplizieren", "duplicateSuffix": "(Kopie)", "deleteError": "Beim Löschen ist ein Fehler aufgetreten.", diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index 5f995fbae4..9b1dafb4d8 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -3389,7 +3389,9 @@ "confirmYes": "Yes", "confirmNo": "No", "saveError": "Error while saving.", - "gapError": "Some days have uncovered time slots (gaps):", + "gapWarning": "Some days are not fully covered. The thermostat will stay on its current preset during these ranges:", + "nextDay": "+1d", + "fullDayHint": "00:00 → 00:00 covers the whole day.", "duplicateButton": "Duplicate", "duplicateSuffix": "(copy)", "deleteError": "Error while deleting.", diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index f8f958c2ce..43bd2f392a 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -3381,7 +3381,7 @@ "noSchedules": "Aucun planning créé. Cliquez sur \"Nouveau planning\" pour commencer.", "nameLabel": "Nom du planning", "namePlaceholder": "Ex: Semaine de travail", - "saveButton": "Enregistrer", + "saveButton": "Sauvegarder", "cancelButton": "Annuler", "deleteButton": "Supprimer", "editButton": "Éditer", @@ -3389,7 +3389,9 @@ "confirmYes": "Oui", "confirmNo": "Non", "saveError": "Erreur lors de la sauvegarde.", - "gapError": "Certains jours n'ont pas de couverture complète (plages manquantes) :", + "gapWarning": "Certains jours ne sont pas couverts en totalité. Le thermostat restera sur son preset courant pendant ces plages :", + "nextDay": "+1j", + "fullDayHint": "00:00 → 00:00 couvre la journée entière.", "duplicateButton": "Dupliquer", "duplicateSuffix": "(copie)", "deleteError": "Erreur lors de la suppression.", diff --git a/front/src/routes/integration/all/thermostat/schedule-page/ScheduleEditor.jsx b/front/src/routes/integration/all/thermostat/schedule-page/ScheduleEditor.jsx index 822ad59e42..f65925ec1d 100644 --- a/front/src/routes/integration/all/thermostat/schedule-page/ScheduleEditor.jsx +++ b/front/src/routes/integration/all/thermostat/schedule-page/ScheduleEditor.jsx @@ -8,6 +8,8 @@ import PRESET_COLORS from '../../../../../utils/thermostatPresetColors'; import { applySlotToDay, mergeIntoSlots, + copyDayOntoDays, + readDayAsEntered, timeToMinutes, minutesToTime, DAY_MINUTES @@ -185,7 +187,13 @@ class ScheduleEditor extends Component { // If end <= start, the user wants overflow past midnight (e.g. 18h→06h) if (newEnd <= newStart) newEnd = newEnd + DAY_MINUTES; - const existingDaySlots = this.state.slots.filter(s => s.day_of_week === dayOfWeek); + // Drop the morning half of the night being edited first: mergeIntoSlots + // only trims what the new overflow overlaps, so shortening 22:30->06:30 + // to 05:00 would leave a stray 05:00->06:30 behind. + const edited = this.state.slots.find(s => s.key === slotKey); + const piece = this.findOvernightPiece(this.state.slots, edited); + const baseSlots = piece ? this.state.slots.filter(s => s.key !== piece.key) : this.state.slots; + const existingDaySlots = baseSlots.filter(s => s.day_of_week === dayOfWeek); const { fixedSlots, overflowSlot } = applySlotToDay( existingDaySlots, @@ -197,7 +205,7 @@ class ScheduleEditor extends Component { slotKey ); const taggedFixed = fixedSlots.map(s => ({ ...s, day_of_week: dayOfWeek })); - const finalSlots = mergeIntoSlots(this.state.slots, dayOfWeek, taggedFixed, overflowSlot); + const finalSlots = mergeIntoSlots(baseSlots, dayOfWeek, taggedFixed, overflowSlot); this.setState(prev => { const forms = { ...prev.editForms }; @@ -208,15 +216,34 @@ class ScheduleEditor extends Component { // ── Remove ──────────────────────────────────────────────────────────────── + // The morning half a night left on the next day, matched on geometry the way + // readDayAsEntered does. The list shows the pair as one slot, so editing or + // removing that slot has to reach this row too — otherwise it survives as an + // orphan the user has no way to see, let alone delete. + findOvernightPiece = (slots, slot) => { + if (!slot || timeToMinutes(slot.end_time) !== 0) { + return null; + } + return ( + slots.find( + s => + s.day_of_week === (slot.day_of_week + 1) % 7 && + timeToMinutes(s.start_time) === 0 && + timeToMinutes(s.end_time) !== 0 && + s.preset === slot.preset + ) || null + ); + }; + removeSlot = slotKey => { - this.setState(prev => ({ - slots: prev.slots.filter(s => s.key !== slotKey), - editForms: (() => { - const forms = { ...prev.editForms }; - delete forms[slotKey]; - return forms; - })() - })); + this.setState(prev => { + const removed = prev.slots.find(s => s.key === slotKey); + const piece = this.findOvernightPiece(prev.slots, removed); + const dropped = new Set([slotKey, ...(piece ? [piece.key] : [])]); + const forms = { ...prev.editForms }; + delete forms[slotKey]; + return { slots: prev.slots.filter(s => !dropped.has(s.key)), editForms: forms }; + }); }; // ── Copy ────────────────────────────────────────────────────────────────── @@ -242,20 +269,23 @@ class ScheduleEditor extends Component { this.closeCopyPicker(); return; } - const daySlots = slots.filter(s => s.day_of_week === copySourceDay); - const otherSlots = slots.filter(s => !copyTargetDays.includes(s.day_of_week)); - const copies = []; - copyTargetDays.forEach(d => { - daySlots.forEach(s => copies.push({ ...s, day_of_week: d, key: Date.now() + d * 100 + Math.random() })); - }); - this.setState({ slots: [...otherSlots, ...copies], copySourceDay: null, copyTargetDays: [] }); + // A night crossing midnight lives as two rows, the second one on the next + // day: copying the source day's rows alone would drop its morning half and + // overwrite that same half on a target. copyDayOntoDays re-joins the pair + // and lays it back down on every target. + const nextSlots = copyDayOntoDays(slots, copySourceDay, copyTargetDays, () => Date.now() + Math.random()); + this.setState({ slots: nextSlots, copySourceDay: null, copyTargetDays: [] }); }; // ── Validation ──────────────────────────────────────────────────────────── + // Uncovered ranges, per day. A gap is not an error: the regulation loop falls + // back on the current preset when no slot matches, which is what a + // daytime-only schedule (offices, 08:00 → 18:00) relies on. It is reported as + // a warning so an unintended hole is still visible before saving. validateSchedule = () => { const { slots } = this.state; - const gapDays = []; + const gaps = []; DAYS.forEach(day => { const daySlots = slots .filter(s => s.day_of_week === day) @@ -265,23 +295,22 @@ class ScheduleEditor extends Component { })) .sort((a, b) => a.start - b.start); - if (daySlots.length === 0) { - gapDays.push(day); - return; - } - - // Check coverage from 0 to DAY_MINUTES + const ranges = []; let covered = 0; - for (const s of daySlots) { + daySlots.forEach(s => { if (s.start > covered) { - gapDays.push(day); - return; + ranges.push({ from: covered, to: s.start }); } covered = Math.max(covered, s.end); + }); + if (covered < DAY_MINUTES) { + ranges.push({ from: covered, to: DAY_MINUTES }); + } + if (ranges.length > 0) { + gaps.push({ day, ranges }); } - if (covered < DAY_MINUTES) gapDays.push(day); }); - return gapDays; + return gaps; }; // ── Save ────────────────────────────────────────────────────────────────── @@ -290,12 +319,7 @@ class ScheduleEditor extends Component { const { name, slots } = this.state; if (!name.trim()) return; - const gapDays = this.validateSchedule(); - if (gapDays.length > 0) { - this.setState({ error: { type: 'gaps', days: gapDays } }); - return; - } - + // Gaps no longer block: they are surfaced as a warning above the form. this.setState({ saving: true, error: null }); const scheduleData = { name: name.trim(), @@ -368,48 +392,59 @@ class ScheduleEditor extends Component { } renderSlotForm(formData, onFieldChange, onConfirm, onCancel, onRemove, dictionary, isEdit) { + // 00:00 → 00:00 is the whole day, which reads as an empty range unless it + // says so: it is what an empty day is prefilled with. + const isFullDay = timeToMinutes(formData.start_time) === 0 && timeToMinutes(formData.end_time) === 0; return ( -
-
- onFieldChange('start_time', e.target.value)} - onChange={e => onFieldChange('start_time', e.target.value)} - /> - - onFieldChange('end_time', e.target.value)} - onChange={e => onFieldChange('end_time', e.target.value)} - /> - - - - {onRemove && ( - + + {onRemove && ( + + )} +
+ {isFullDay && ( +

+ + +

)}
); @@ -423,6 +458,9 @@ class ScheduleEditor extends Component { intl && intl.dictionary && intl.dictionary.integration && intl.dictionary.integration.thermostat ? intl.dictionary.integration.thermostat.schedule : {}; + // An empty schedule is a schedule being started, not one with holes: the + // warning would name all seven days before the user has typed anything. + const gaps = slots.length === 0 ? [] : this.validateSchedule(); return (
@@ -438,20 +476,25 @@ class ScheduleEditor extends Component {
{error && (
- {error && error.type === 'gaps' ? ( - - {' '} - {error.days.map(d => ( - - - - ))} - - ) : typeof error === 'string' ? ( - error - ) : ( - - )} + {typeof error === 'string' ? error : } +
+ )} + + {gaps.length > 0 && ( +
+
+ + +
+
    + {gaps.map(gap => ( +
  • + + {' : '} + {gap.ranges.map(range => `${minutesToTime(range.from)} → ${minutesToTime(range.to)}`).join(', ')} +
  • + ))} +
)} @@ -470,9 +513,17 @@ class ScheduleEditor extends Component {
{DAYS.map(day => { - const daySlots = slots + // The bar draws what this day actually covers, so it keeps the + // stored rows: a night is a segment up to midnight here, and its + // morning half belongs to the next day's bar. + const barSlots = slots .filter(s => s.day_of_week === day) .sort((a, b) => timeToMinutes(a.start_time) - timeToMinutes(b.start_time)); + // The list shows what the user typed: a night reads back as + // 22:30 → 06:30 (+1d) rather than a truncated 22:30 → 00:00. + const daySlots = readDayAsEntered(slots, day).sort( + (a, b) => timeToMinutes(a.start_time) - timeToMinutes(b.start_time) + ); const isOpen = selectedDay === day; const newForm = newSlotForms[day]; @@ -485,7 +536,7 @@ class ScheduleEditor extends Component {
- {this.renderTimeBar(daySlots)} + {this.renderTimeBar(barSlots)}
{isOpen && ( @@ -528,6 +579,11 @@ class ScheduleEditor extends Component { {slot.start_time} {slot.end_time} + {slot.overnight && ( + + + + )} {(dictionary.presets && dictionary.presets[slot.preset]) || slot.preset} diff --git a/front/src/routes/integration/all/thermostat/schedule-page/style.css b/front/src/routes/integration/all/thermostat/schedule-page/style.css index 98f60b267c..41b9622c2a 100644 --- a/front/src/routes/integration/all/thermostat/schedule-page/style.css +++ b/front/src/routes/integration/all/thermostat/schedule-page/style.css @@ -140,6 +140,30 @@ color: #495057; } +/* Marks the end time of a slot that runs past midnight, so the row reads + 22:30 → 06:30 +1d instead of losing the morning the user typed. */ +.slotNextDay { + font-size: 0.7rem; + font-weight: 600; + color: #6c757d; + background: rgba(0, 0, 0, 0.05); + border-radius: 4px; + padding: 1px 4px; + flex-shrink: 0; +} + +.gapWarningTitle { + display: flex; + align-items: center; + font-weight: 600; +} + +.gapWarningList { + margin: 4px 0 0; + padding-left: 24px; + font-size: 0.88rem; +} + .slotEditIcon { color: #adb5bd; font-size: 0.8rem; @@ -180,10 +204,12 @@ filter: invert(100%) hue-rotate(180deg); } - +/* Not a fixed width: in a 12-hour locale the input also renders an AM/PM + indicator, which a hard 90px cut off. The field sizes itself to its content + and only takes 90px as a floor. */ .slotTimeInput { - width: 90px !important; - min-width: 0; + width: auto !important; + min-width: 90px; flex-shrink: 0; } @@ -213,7 +239,7 @@ gap: 6px; margin-top: 8px; padding: 8px 10px; - border: 1px solid rgba(0,40,100,0.12); + border: 1px solid rgba(0, 40, 100, 0.12); border-radius: 4px; font-size: 0.82rem; } @@ -247,7 +273,7 @@ border-radius: 3px; margin-bottom: 12px; background: #fff; - box-shadow: 0 1px 2px 0 rgba(0,0,0,.05); + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); } .scheduleCardHeader { @@ -314,3 +340,18 @@ display: block; margin-bottom: 8px; } + +.slotFormWrapper { + margin-bottom: 8px; +} + +.slotFormWrapper .newSlotForm, +.slotFormWrapper .editSlotForm { + margin-bottom: 0; +} + +.fullDayHint { + margin: 4px 0 0; + font-size: 0.78rem; + color: #6c757d; +} From cc68c715ca3aea31e001198359b94e45162203a1 Mon Sep 17 00:00:00 2001 From: William Deren Date: Mon, 24 Aug 2026 22:17:57 +0200 Subject: [PATCH 19/29] feat(thermostat): report the suspended state, and summarise the device card An open window cuts the switch, but the widget only swapped a pictogram for it: the arc stayed orange and the banner kept announcing "Comfort until 22:30" while the heating was held off. The arc now greys out like the off mode, and a banner names the state -- and names it for the right appliance, since the server cuts the switch whatever the mode, so an air conditioner is suspended exactly like a heater. It reads configMode rather than mode, which turns to 'off' on an off preset and would have called a stopped air conditioner a heater. It also renders before the `activePreset === null` guard: a thermostat with no preset is precisely one whose state needs explaining. The gauge draws its icons from the icon font instead of native emoji, which rendered at a different size and shape on every OS. SVG cannot use the `fe fe-*` classes -- they work through a :before pseudo-element -- so the codepoints are inlined. There is no window glyph among the 480 available, and neither blinds nor a door is a window, so that one is dropped: the banner already says it. Being glyphs, they take a fill, which the droplet uses to stay blue while its reading stays grey. The device card showed a name and a room. updateActiveSchedule existed but was wired to nothing, so the schedule and the setpoint required opening the edit page to see. Both are summarised on the card now, read from data it already had. Deleting is confirmed the way the schedule page confirms it; the confirmation takes the whole button row rather than adding to it, since four flex-fill buttons in a col-md-6 card cut their own labels off. Also: the French "Enregistrer" of the two forms becomes "Sauvegarder", which is what the card and the other integrations' device pages use. Co-Authored-By: Claude Opus 5 --- docs/specs/thermostat.md | 2 +- .../boxs/thermostat/CircularGauge.jsx | 46 ++++++-- .../boxs/thermostat/ThermostatBox.jsx | 22 +++- .../src/components/boxs/thermostat/style.css | 87 ++++++++++++-- front/src/config/i18n/de.json | 8 ++ front/src/config/i18n/en.json | 8 ++ front/src/config/i18n/fr.json | 10 +- .../device-page/ThermostatDeviceBox.jsx | 107 ++++++++++++++---- .../all/thermostat/device-page/style.css | 49 ++++++++ 9 files changed, 293 insertions(+), 46 deletions(-) diff --git a/docs/specs/thermostat.md b/docs/specs/thermostat.md index e965b8f57b..c9a016f548 100644 --- a/docs/specs/thermostat.md +++ b/docs/specs/thermostat.md @@ -29,7 +29,7 @@ Everything the control loop needs is a `THERMOSTAT_*` device param: | `THERMOSTAT_TEMPERATURE_FEATURE` | the sensor the loop regulates on | | `THERMOSTAT_HUMIDITY_FEATURE` | optional, displayed only | | `THERMOSTAT_SWITCH_FEATURE` | the actuator the loop drives | -| `THERMOSTAT_WINDOW_FEATURE` | optional opening sensor, cuts the heating when open | +| `THERMOSTAT_WINDOW_FEATURE` | optional opening sensor, cuts the switch when open — whatever the mode, so a running air conditioner is suspended like a heater | | `THERMOSTAT_ACTIVE_SCHEDULE` | selector of the weekly schedule to follow, empty for none | | `THERMOSTAT_MODE` | `heating` or `cooling` | | `THERMOSTAT_CONTROL_TYPE` | `hysteresis` or `tpi` | diff --git a/front/src/components/boxs/thermostat/CircularGauge.jsx b/front/src/components/boxs/thermostat/CircularGauge.jsx index 6186ff7068..9602239a37 100644 --- a/front/src/components/boxs/thermostat/CircularGauge.jsx +++ b/front/src/components/boxs/thermostat/CircularGauge.jsx @@ -5,6 +5,16 @@ import style from './style.css'; export const ARC_DEGREES = 240; export const ARC_START_ANGLE = 150; +// Feather/Lucide glyphs, by codepoint. Native emoji render differently on every +// OS — and at different sizes — where the rest of Gladys draws its icons from +// this font; SVG cannot use the `fe fe-*` classes, which work through a +// :before pseudo-element, so the codepoints are inlined here. +const ICONS = { + droplet: '\ue0b4', + flame: '\ue0d2', + snowflake: '\ue165' +}; + /** * Convert a polar coordinate (angle in degrees, 0 = 12 o'clock) to cartesian. */ @@ -44,7 +54,11 @@ const CircularGauge = ({ const bgPath = describeArc(cx, cy, r, ARC_START_ANGLE, ARC_START_ANGLE + ARC_DEGREES); const fgPath = describeArc(cx, cy, r, ARC_START_ANGLE, arcEnd); const knob = polarToCartesian(cx, cy, r, arcEnd); - const arcColor = mode === 'cooling' ? '#3b82f6' : mode === 'off' ? '#adb5bd' : '#f97316'; + // An open window suspends the heating, so the arc goes grey like the off mode: + // leaving it orange showed a thermostat calling for heat while the switch was + // being held off, which is the one thing the gauge must not misreport. + const baseArcColor = mode === 'cooling' ? '#3b82f6' : mode === 'off' ? '#adb5bd' : '#f97316'; + const arcColor = isWindowOpen ? '#adb5bd' : baseArcColor; // Derive both halves from one rounded value: splitting the raw setpoint made // 20.96 render as "20.10" (the decimal carried to 10) and -3.5 as "-4.5" // (floor rounds away from zero for negatives). @@ -102,7 +116,8 @@ const CircularGauge = ({ dominantBaseline="middle" class={style.humidityText} > - {`\u{1F4A7} ${Math.round(humidity)} %`} + {ICONS.droplet} + {` ${Math.round(humidity)} %`} )} @@ -121,19 +136,28 @@ const CircularGauge = ({ {/* Active icon: at bottom of gauge */} - {isWindowOpen && ( - - 🪟 - - )} + {/* No icon for an open window: the icon font has no window glyph, and the + state is already named by the banner under the gauge. */} {!isWindowOpen && isActive && mode === 'heating' && ( - - 🔥 + + {ICONS.flame} )} {!isWindowOpen && isActive && mode === 'cooling' && ( - - ❄️ + + {ICONS.snowflake} )} diff --git a/front/src/components/boxs/thermostat/ThermostatBox.jsx b/front/src/components/boxs/thermostat/ThermostatBox.jsx index 8035a51920..a17939ec0b 100644 --- a/front/src/components/boxs/thermostat/ThermostatBox.jsx +++ b/front/src/components/boxs/thermostat/ThermostatBox.jsx @@ -1014,7 +1014,27 @@ class ThermostatBox extends Component {
- {activePreset === null + {isWindowOpen && ( +
+ {/* An alert glyph, not a window: the icon font has none, and + what matters here is that the heating is suspended. */} + + + {/* The server cuts the switch whatever the mode, so an open + window suspends a running air conditioner just as it + suspends a heater: the banner has to name the right one. */} + + +
+ )} + + {isWindowOpen || activePreset === null ? null : (() => { const hasSchedule = !!activeSchedule; diff --git a/front/src/components/boxs/thermostat/style.css b/front/src/components/boxs/thermostat/style.css index 585a4285c2..b997e02e02 100644 --- a/front/src/components/boxs/thermostat/style.css +++ b/front/src/components/boxs/thermostat/style.css @@ -76,16 +76,40 @@ fill: #6c757d; } +/* The droplet keeps its own colour: the reading beside it stays grey like the + other secondary text. */ +.humidityIcon { + fill: #3b82f6; +} + @keyframes flamePulse { - 0% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.7; transform: scale(1.25); } - 100% { opacity: 1; transform: scale(1); } + 0% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.7; + transform: scale(1.25); + } + 100% { + opacity: 1; + transform: scale(1); + } } @keyframes frostSpin { - 0% { opacity: 1; transform: rotate(0deg) scale(1); } - 50% { opacity: 0.6; transform: rotate(180deg) scale(1.15); } - 100% { opacity: 1; transform: rotate(360deg) scale(1); } + 0% { + opacity: 1; + transform: rotate(0deg) scale(1); + } + 50% { + opacity: 0.6; + transform: rotate(180deg) scale(1.15); + } + 100% { + opacity: 1; + transform: rotate(360deg) scale(1); + } } /* Kept deliberately tight. A wide shadow spreads the arc's colour over the pale @@ -93,7 +117,8 @@ halo — very visible on the blue of cooling mode, which has less contrast against that background than the orange of heating. */ @keyframes arcGlowPulse { - 0%, 100% { + 0%, + 100% { filter: drop-shadow(0 0 3px currentColor); } 50% { @@ -109,8 +134,18 @@ animation: arcGlowPulse 2s ease-in-out infinite; } +/* Draws SVG text with the icon font the rest of Gladys uses. The `fe fe-*` + classes render through a :before pseudo-element, which SVG has none + of, so the glyph is inlined and only the font is set here. */ +.gaugeIconGlyph { + font-family: 'lucide'; + font-weight: normal; + font-style: normal; +} + .activeIconHeating { font-size: 22px; + fill: #f97316; animation: flamePulse 1.4s ease-in-out infinite; transform-origin: center; transform-box: fill-box; @@ -118,6 +153,7 @@ .activeIconCooling { font-size: 22px; + fill: #3b82f6; animation: frostSpin 2s linear infinite; transform-origin: center; transform-box: fill-box; @@ -203,7 +239,6 @@ color: #467fcf; } - /* Segmented Control */ .segmentedControl { display: flex; @@ -287,7 +322,6 @@ filter: invert(100%) hue-rotate(180deg) !important; } - .presetColorLabel { font-size: 0.8rem; color: #6c757d; @@ -322,7 +356,7 @@ .scheduleSelect { flex: 1; font-size: 0.8rem; - border: 1px solid rgba(0,40,100,0.12); + border: 1px solid rgba(0, 40, 100, 0.12); border-radius: 3px; padding: 3px 6px; cursor: pointer; @@ -381,6 +415,38 @@ flex-shrink: 0; } +/* An open window suspends the heating: the state is reported on its own banner + rather than only as a pictogram, and it replaces the schedule/manual banner + because neither describes what the thermostat is actually doing right now. */ +.windowBanner { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px 12px; + border-radius: 20px; + border: 1px solid #0ca6f240; + background: #0ca6f214; + color: #0ca6f2; + font-size: 0.875rem; + font-weight: 500; + margin: 0 auto; + width: fit-content; +} + +:global(.dark-mode) .windowBanner { + filter: invert(100%) hue-rotate(180deg) !important; +} + +.windowBannerIcon { + font-size: 0.9rem; + flex-shrink: 0; +} + +.windowBannerText { + white-space: nowrap; +} + .manualBannerText { white-space: nowrap; } @@ -463,4 +529,3 @@ font-family: inherit; cursor: pointer; } - diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index 4f03b2c6b7..67332a5653 100644 --- a/front/src/config/i18n/de.json +++ b/front/src/config/i18n/de.json @@ -732,6 +732,8 @@ "scheduleUntil": "bis", "manualUntil": "bis", "cancelManual": "Manuellen Modus beenden", + "windowOpen": "Fenster offen — Heizung ausgesetzt", + "windowOpenCooling": "Fenster offen — Kühlung ausgesetzt", "error": "Fehler beim Laden der Thermostat-Daten.", "preset": { "off": "Aus", @@ -3292,9 +3294,15 @@ "nameLabel": "Name", "roomLabel": "Raum", "activeScheduleLabel": "Aktiver Zeitplan", + "noSchedule": "Kein Zeitplan", + "setpointLabel": "Sollwert", + "noSetpoint": "—", "saveButton": "Speichern", "editButton": "Bearbeiten", "deleteButton": "Löschen", + "confirmDelete": "Dieses Thermostat löschen?", + "confirmYes": "Ja", + "confirmNo": "Nein", "saveError": "Beim Speichern ist ein Fehler aufgetreten.", "deleteError": "Beim Löschen ist ein Fehler aufgetreten." }, diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index 9b1dafb4d8..d198e2852b 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -842,6 +842,8 @@ "scheduleUntil": "until", "manualUntil": "until", "cancelManual": "Cancel manual mode", + "windowOpen": "Window open — heating suspended", + "windowOpenCooling": "Window open — cooling suspended", "error": "Error loading thermostat data.", "preset": { "off": "Off", @@ -3292,9 +3294,15 @@ "nameLabel": "Name", "roomLabel": "Room", "activeScheduleLabel": "Active schedule", + "noSchedule": "No schedule", + "setpointLabel": "Setpoint", + "noSetpoint": "—", "saveButton": "Save", "editButton": "Edit", "deleteButton": "Delete", + "confirmDelete": "Delete this thermostat?", + "confirmYes": "Yes", + "confirmNo": "No", "saveError": "Error while saving.", "deleteError": "Error while deleting." }, diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 43bd2f392a..09e561a341 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -732,6 +732,8 @@ "scheduleUntil": "jusqu'à", "manualUntil": "jusqu'à", "cancelManual": "Annuler le mode manuel", + "windowOpen": "Fenêtre ouverte — chauffage suspendu", + "windowOpenCooling": "Fenêtre ouverte — climatisation suspendue", "error": "Erreur lors du chargement des données du thermostat.", "preset": { "off": "Arrêt", @@ -3292,9 +3294,15 @@ "nameLabel": "Nom", "roomLabel": "Pièce", "activeScheduleLabel": "Planning actif", + "noSchedule": "Aucun planning", + "setpointLabel": "Consigne", + "noSetpoint": "—", "saveButton": "Sauvegarder", "editButton": "Éditer", "deleteButton": "Supprimer", + "confirmDelete": "Supprimer ce thermostat ?", + "confirmYes": "Oui", + "confirmNo": "Non", "saveError": "Erreur lors de la sauvegarde.", "deleteError": "Erreur lors de la suppression." }, @@ -3365,7 +3373,7 @@ "night": "Nuit", "comfort": "Confort" }, - "saveButton": "Enregistrer", + "saveButton": "Sauvegarder", "cancelButton": "Annuler", "saveError": "Erreur lors de la sauvegarde.", "activeScheduleLabel": "Planning actif", diff --git a/front/src/routes/integration/all/thermostat/device-page/ThermostatDeviceBox.jsx b/front/src/routes/integration/all/thermostat/device-page/ThermostatDeviceBox.jsx index 2d79fdc869..f192134a35 100644 --- a/front/src/routes/integration/all/thermostat/device-page/ThermostatDeviceBox.jsx +++ b/front/src/routes/integration/all/thermostat/device-page/ThermostatDeviceBox.jsx @@ -16,8 +16,18 @@ class ThermostatDeviceBox extends Component { this.setState({ saving: false }); }; + askDelete = () => this.setState({ confirmDelete: true }); + + cancelDelete = () => this.setState({ confirmDelete: false }); + deleteDevice = async () => { - this.setState({ deleting: true, tooMuchStatesError: false, statesNumber: undefined, deleteError: false }); + this.setState({ + deleting: true, + confirmDelete: false, + tooMuchStatesError: false, + statesNumber: undefined, + deleteError: false + }); try { await this.props.deleteDevice(this.props.device, this.props.deviceIndex); } catch (e) { @@ -45,9 +55,22 @@ class ThermostatDeviceBox extends Component { this.props.updateDeviceProperty(this.props.deviceIndex, 'active_schedule', e.target.value); }; - render(props, { saving, deleting, saveError, deleteError, tooMuchStatesError, statesNumber }) { + render(props, { saving, deleting, saveError, deleteError, tooMuchStatesError, statesNumber, confirmDelete }) { const { device } = props; const loading = saving || deleting; + // Both are read-only here: the card summarises what the thermostat is set to, + // while changing either stays in the edit page. + const activeSchedule = (props.thermostatSchedules || []).find(s => s.selector === device.active_schedule); + const scheduleName = activeSchedule ? activeSchedule.name : null; + const setpointFeature = (device.features || []).find( + f => f.category === 'thermostat' && f.type === 'target-temperature' + ); + const setpoint = + setpointFeature && setpointFeature.last_value !== null && setpointFeature.last_value !== undefined + ? setpointFeature.last_value + : null; + const unitParam = (device.params || []).find(p => p.name === 'THERMOSTAT_TEMP_UNIT'); + const tempUnitValue = unitParam && unitParam.value ? unitParam.value : 'C'; return (
@@ -101,26 +124,68 @@ class ThermostatDeviceBox extends Component {
-
- - - - - +
+
+ + + + + {scheduleName || } + +
+
+ + + + + {setpoint === null ? ( + + ) : ( + `${setpoint} °${tempUnitValue}` + )} + +
+ + {confirmDelete ? ( + // The confirmation takes over the whole row: keeping Save and + // Edit alongside it would put four buttons in a col-md-6 card, + // where flex-fill shrinks them until the labels are cut off. +
+ + + +
+ + +
+
+ ) : ( +
+ + + + + +
+ )}
diff --git a/front/src/routes/integration/all/thermostat/device-page/style.css b/front/src/routes/integration/all/thermostat/device-page/style.css index a1f7c749db..eebb306b3d 100644 --- a/front/src/routes/integration/all/thermostat/device-page/style.css +++ b/front/src/routes/integration/all/thermostat/device-page/style.css @@ -2,3 +2,52 @@ display: flex; gap: 8px; } + +/* Wraps on a narrow card: the question goes on its own line and the two + answers keep a usable width instead of being squeezed beside it. */ +.confirmDeleteRow { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.confirmDeleteRow .buttonGroup { + flex: 1; + min-width: 160px; +} + +.confirmDeleteText { + font-weight: 500; +} + +/* Read-only summary of what the thermostat is set to, so the card answers the + two questions that used to require opening the edit page. */ +.summaryRow { + display: flex; + flex-wrap: wrap; + gap: 8px 24px; + margin-bottom: 16px; +} + +.summaryItem { + display: flex; + flex-direction: column; + min-width: 0; +} + +.summaryLabel { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #9aa0ac; +} + +.summaryValue { + font-size: 0.9rem; + font-weight: 500; + color: #495057; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} From aa8d5669b45e0196873af0f883266e552c61d629 Mon Sep 17 00:00:00 2001 From: William Deren Date: Mon, 24 Aug 2026 23:20:51 +0200 Subject: [PATCH 20/29] fix(thermostat): make the widget reachable by touch, keyboard and screen readers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gauge took the whole SVG out of touch scrolling and called preventDefault before knowing whether the press was even on the ring, so a thumb starting anywhere on it — the middle, where the temperatures are — found a dead patch instead of the page moving. On a phone the gauge spans the widget, so a dashboard of thermostats was mostly dead patches. Only the ring opts out now, and the default is prevented once the angle is known to be on it; a drag that does start there is still held by the non-passive touchmove listener. The widget was also invisible to a keyboard and to screen readers. Nothing carried a role, an aria attribute or a tabindex: the +/- controls were unfocusable elements, the dial could not be operated without a pointer at all, and the SVG texts were read a fragment at a time ("21", ".0", "°", "C"). It is a slider now — arrow keys move the setpoint, and one aria-label states it as a sentence while the individual texts leave the tree. That also answers the case where it mattered most: with a schedule attached the preset bar gives way to the banner, and the dial was then the only control left, so there was no accessible way to change a temperature at all. The +/- stay out of the tree deliberately. The arrow keys already cover the keyboard, and exposing them too would have a reader announce three ways to change one value. Alongside: focus is restored on :focus-visible rather than suppressed outright (the dial being tabbable now made this necessary, not just advisable), the manual-cancel button and the +/- get a tap area that meets the touch-target minimums without changing how they look, and the three infinite animations stop under prefers-reduced-motion — the glow keeps its colour, it just stops pulsing. Co-Authored-By: Claude Opus 5 --- .../boxs/thermostat/CircularGauge.jsx | 119 ++++++++++++++++-- .../boxs/thermostat/ThermostatBox.jsx | 17 ++- .../src/components/boxs/thermostat/style.css | 69 +++++++++- front/src/config/i18n/de.json | 3 + front/src/config/i18n/en.json | 3 + front/src/config/i18n/fr.json | 3 + 6 files changed, 199 insertions(+), 15 deletions(-) diff --git a/front/src/components/boxs/thermostat/CircularGauge.jsx b/front/src/components/boxs/thermostat/CircularGauge.jsx index 9602239a37..6a09aa418f 100644 --- a/front/src/components/boxs/thermostat/CircularGauge.jsx +++ b/front/src/components/boxs/thermostat/CircularGauge.jsx @@ -42,7 +42,8 @@ const CircularGauge = ({ mode, isActive, isWindowOpen, - tempUnit + tempUnit, + a11yLabels = {} }) => { const cx = 110; const cy = 110; @@ -75,9 +76,54 @@ const CircularGauge = ({ const hasCurrentTemp = currentTemp !== null && currentTemp !== undefined; const hasHumidity = humidity !== null && humidity !== undefined; + // One sentence for a screen reader, instead of the raw SVG texts being read + // one fragment at a time ("21", ".0", "\u00b0", "C"). The individual + // nodes are hidden from the tree for the same reason. + const unit = `\u00b0${tempUnit || 'C'}`; + const label = [ + `${a11yLabels.setpoint || 'Setpoint'} ${roundedSetpoint} ${unit}`, + hasCurrentTemp + ? `${a11yLabels.currentTemp || 'Current temperature'} ${Number(currentTemp).toFixed(1)} ${unit}` + : null, + hasHumidity ? `${a11yLabels.humidity || 'Humidity'} ${Math.round(humidity)} %` : null, + isWindowOpen ? a11yLabels.windowOpen || null : null + ] + .filter(Boolean) + .join(', '); + + // Arrow keys move the setpoint, which is what role="slider" promises. Without + // this the dial is the only way to set a temperature, and a dial cannot be + // operated from a keyboard at all. + const onKeyDown = event => { + if (!onIncrement && !onDecrement) { + return; + } + if (event.key === 'ArrowUp' || event.key === 'ArrowRight') { + event.preventDefault(); + if (onIncrement) onIncrement(); + } else if (event.key === 'ArrowDown' || event.key === 'ArrowLeft') { + event.preventDefault(); + if (onDecrement) onDecrement(); + } + }; + + const interactive = !!(onIncrement || onDecrement || onPointerDown); + return ( - - + + {/* The glow marks "running right now", which is just as true of a running air conditioner as of a running heater, so it applies in both modes. It is a drop-shadow rather than a feGaussianBlur/feMerge filter: merging @@ -86,7 +132,7 @@ const CircularGauge = ({ leaves the stroke untouched and only casts colour around it. */} - + {/* Current temp + humidity: above setpoint */} {hasCurrentTemp && (
diff --git a/front/src/components/boxs/thermostat/style.css b/front/src/components/boxs/thermostat/style.css index b997e02e02..d1c79d9f4f 100644 --- a/front/src/components/boxs/thermostat/style.css +++ b/front/src/components/boxs/thermostat/style.css @@ -24,10 +24,17 @@ fill: #dee2e6; } +/* Invisible, but it receives the pointer: the visible circle is 30px across in + a gauge that renders at 220px, which is under every touch-target minimum. */ +.arcBtnHitArea { + fill: transparent; +} + .arcBtnCircle { fill: white; stroke: #adb5bd; stroke-width: 1.5; + pointer-events: none; } :global(.dark-mode) .arcBtnCircle { @@ -54,12 +61,33 @@ width: 100%; height: auto; display: block; - touch-action: none; cursor: pointer; user-select: none; -webkit-user-select: none; } +/* The gauge is reachable by tab now that it is a slider, so it has to show it. + :focus-visible only fires for keyboard focus, so tapping the dial does not + draw a ring around it. */ +.gaugeSvg:focus { + outline: none; +} + +.gaugeSvg:focus-visible { + outline: 2px solid #206bc4; + outline-offset: 2px; + border-radius: 8px; +} + +/* Only the ring opts out of touch scrolling, not the whole gauge: the SVG spans + the widget on a phone, and taking the gesture everywhere turned each + thermostat on a dashboard into a patch the page would not scroll under. + A drag that does start on the ring is held by the non-passive touchmove + listener, which preventDefaults once the gesture is under way. */ +.gaugeArc { + touch-action: none; +} + .arcLabel { font-size: 9px; fill: #adb5bd; @@ -265,11 +293,19 @@ outline: none; } +/* Cleared for the pointer, restored for the keyboard: :focus-visible only + matches when the browser judges focus should be shown, so tapping a preset + stays clean while tabbing through them is followable. */ .segmentBtn:focus { outline: none; box-shadow: none; } +.segmentBtn:focus-visible { + outline: 2px solid #206bc4; + outline-offset: 2px; +} + .segmentBtn:hover { transform: translateY(-2px); } @@ -367,6 +403,11 @@ border-color: #467fcf; } +.scheduleSelect:focus-visible { + outline: 2px solid #206bc4; + outline-offset: 1px; +} + /* Schedule banner (planning mode) */ .scheduleBanner { display: flex; @@ -457,6 +498,7 @@ } .manualBannerCancel { + position: relative; flex-shrink: 0; background: none; border: 1px solid #ffc10760; @@ -474,6 +516,19 @@ transition: background 0.15s; } +/* The visible circle stays 22px, which suits the banner, but the tap area is + extended to the 44px Apple asks for (WCAG 2.5.8 wants 24 minimum). This is + the control that ends a forced heating: it has to be reliable under a thumb. */ +.manualBannerCancel::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 44px; + height: 44px; + transform: translate(-50%, -50%); +} + .manualBannerCancel:hover { background: #ffc10730; } @@ -529,3 +584,15 @@ font-family: inherit; cursor: pointer; } + +/* Three infinite animations run whenever the thermostat is heating or cooling, + which is most of the time on a wall tablet. Users who ask for less motion get + the same information without the movement: the glow keeps its colour, it just + stops pulsing. */ +@media (prefers-reduced-motion: reduce) { + .arcGlow, + .activeIconHeating, + .activeIconCooling { + animation: none; + } +} diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index 67332a5653..c419aaecd4 100644 --- a/front/src/config/i18n/de.json +++ b/front/src/config/i18n/de.json @@ -734,6 +734,9 @@ "cancelManual": "Manuellen Modus beenden", "windowOpen": "Fenster offen — Heizung ausgesetzt", "windowOpenCooling": "Fenster offen — Kühlung ausgesetzt", + "a11ySetpoint": "Sollwert", + "a11yCurrentTemp": "Gemessene Temperatur", + "a11yHumidity": "Luftfeuchtigkeit", "error": "Fehler beim Laden der Thermostat-Daten.", "preset": { "off": "Aus", diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index d198e2852b..9a51b1760f 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -844,6 +844,9 @@ "cancelManual": "Cancel manual mode", "windowOpen": "Window open — heating suspended", "windowOpenCooling": "Window open — cooling suspended", + "a11ySetpoint": "Setpoint", + "a11yCurrentTemp": "Current temperature", + "a11yHumidity": "Humidity", "error": "Error loading thermostat data.", "preset": { "off": "Off", diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 09e561a341..aaf9e270c2 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -734,6 +734,9 @@ "cancelManual": "Annuler le mode manuel", "windowOpen": "Fenêtre ouverte — chauffage suspendu", "windowOpenCooling": "Fenêtre ouverte — climatisation suspendue", + "a11ySetpoint": "Consigne", + "a11yCurrentTemp": "Température mesurée", + "a11yHumidity": "Humidité", "error": "Erreur lors du chargement des données du thermostat.", "preset": { "off": "Arrêt", From f372a9b220af6ce1d40d88bf95c4d3c9f347c482 Mon Sep 17 00:00:00 2001 From: William Deren Date: Mon, 24 Aug 2026 23:21:06 +0200 Subject: [PATCH 21/29] fix(thermostat): make the schedule editor operable without a pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The day rows were plain
: no role, no tabindex, no key handling, so a keyboard could not open a day at all. The slot rows were worse in a quieter way — they carried role="button" and tabIndex but no onKeyDown, announcing a button that did nothing when activated. Both are operable now, and the day row reports whether it is expanded. The coloured bar has no text equivalent, so a collapsed day said nothing at all: describeDay states the same ranges in words ("Comfort 06:30 – 08:30, Eco 08:30 – 17:00"), and the bar itself leaves the accessibility tree — its hour markers would otherwise be read as loose numbers after the summary. Co-Authored-By: Claude Opus 5 --- .../schedule-page/ScheduleEditor.jsx | 45 +++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/front/src/routes/integration/all/thermostat/schedule-page/ScheduleEditor.jsx b/front/src/routes/integration/all/thermostat/schedule-page/ScheduleEditor.jsx index f65925ec1d..45ebbe495d 100644 --- a/front/src/routes/integration/all/thermostat/schedule-page/ScheduleEditor.jsx +++ b/front/src/routes/integration/all/thermostat/schedule-page/ScheduleEditor.jsx @@ -345,6 +345,21 @@ class ScheduleEditor extends Component { // ── Render helpers ──────────────────────────────────────────────────────── + // Text equivalent of the coloured bar, for a collapsed day. + describeDay(daySlots, dictionary) { + if (!daySlots || daySlots.length === 0) { + return dictionary.noSlots || ''; + } + return daySlots + .slice() + .sort((a, b) => timeToMinutes(a.start_time) - timeToMinutes(b.start_time)) + .map(slot => { + const preset = (dictionary.presets && dictionary.presets[slot.preset]) || slot.preset; + return `${preset} ${slot.start_time} – ${slot.end_time}`; + }) + .join(', '); + } + renderTimeBar(daySlots) { const sorted = daySlots.slice().sort((a, b) => timeToMinutes(a.start_time) - timeToMinutes(b.start_time)); const segments = []; @@ -370,7 +385,9 @@ class ScheduleEditor extends Component { } return ( -
+ // Purely visual: the colours carry no text and the hour markers would be + // read as loose numbers. describeDay states the same thing in words. +