/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { Behavior } from "#behavior/Behavior.js"; import { ActionContext } from "#behavior/context/ActionContext.js"; import { ValueSupervisor } from "#behavior/supervision/ValueSupervisor.js"; import { OccupancySensingServer } from "#behaviors/occupancy-sensing"; import { TemperatureMeasurementServer } from "#behaviors/temperature-measurement"; import { Endpoint } from "#endpoint/Endpoint.js"; import { Node } from "#node/Node.js"; import { ServerNode } from "#node/ServerNode.js"; import { Bytes, cropValueRange, Entropy, ImplementationError, InternalError, Logger, Observable, } from "@matter/general"; import { FieldElement } from "@matter/model"; import { hasLocalActor, Val } from "@matter/protocol"; import { StatusResponse } from "@matter/types"; import { OccupancySensing } from "@matter/types/clusters/occupancy-sensing"; import { Thermostat } from "@matter/types/clusters/thermostat"; import { AtomicWriteHandler } from "./AtomicWriteHandler.js"; import { ThermostatBehavior } from "./ThermostatBehavior.js"; const logger = Logger.get("ThermostatServer"); // Enable some features we need for implementation, they will be reset at the end again const ThermostatBehaviorLogicBase = ThermostatBehavior.with( Thermostat.Feature.Heating, Thermostat.Feature.Cooling, Thermostat.Feature.Occupancy, Thermostat.Feature.AutoMode, Thermostat.Feature.Presets, ); // Enhance Schema to define conformance for some of the additional state attributes const schema = ThermostatBehaviorLogicBase.schema.extend({ children: [ FieldElement({ name: "PersistedPresets", type: "list", conformance: "[PRES]", quality: "N", children: [FieldElement({ name: "entry", type: "PresetStruct" })], }), ], }); /** * This is the default server implementation of {@link ThermostatBehavior}. * * The Matter specification requires the Thermostat cluster to support features we do not enable by default. You should * use {@link ThermostatServer.with} to specialize the class for the features your implementation supports. * We implement all features beside the following: * * MatterScheduleConfiguration: This feature is provisional. * * ScheduleConfiguration: This feature is deprecated and not allowed to be enabled. * * Setback: This feature is considered deprecated. * * The use of the "setpointHoldExpiryTimestamp" attribute is currently not supported. * * This implementation mainly provides all validation and base logic required by the Matter specification. * It implements some thermostat logic, partially beyond Matter specification definition, notably: * * Adjust the setpoints when a preset is activated * If this behavior is not desired, you can override the setActivePresetRequest method but should call * handleSetActivePresetRequest() to ensure compliance with the specification. * * The implementation also adds enhanced system mode logic that can be enabled by setting the state field * useAutomaticModeManagement to true. When enabled, the thermostat will: * * Adjust the thermostat running mode when in Auto system mode and the Setback feature is also supported * * Determine the system mode and/or running mode based on temperature changes * * For local temperature or occupancy values we check if there is a local cluster available on the same endpoint and use * them, alternatively raw measurements can be set in the states externalMeasuredIndoorTemperature and * externallyMeasuredOccupancy. The OutdoorTemperature can be set directly on the attribute if supported. * The RemoteSensing attribute need to be set correctly as needed by the developer to identify the measurement source. * * The following custom events are provided: * * calibratedTemperature$Changed: Emitted when the measured local temperature changes including any calibration applied. This event is mainly useful when the localTemperatureNotExposed feature is used. * * Important note: To access the current local temperature (including all calibrations applied) please use * this.internal.localTemperature because the localTemperature attribute in state might be null depending on the * configured features. * * TODO: Currently the general purpose "atomic write" Matter feature is only implemented in this specific cluster because * only used here so far. Also see information in AtomicWriteHandler.ts. */ export class ThermostatBaseServer extends ThermostatBehaviorLogicBase { declare protected internal: ThermostatBaseServer.Internal; declare readonly state: ThermostatBaseServer.State; declare readonly events: ThermostatBaseServer.Events; static override readonly schema = schema; override async initialize() { if (this.features.scheduleConfiguration) { throw new ImplementationError("ScheduleConfiguration features is deprecated and not allowed to be enabled"); } if (this.features.setback) { throw new ImplementationError("Setback feature is deprecated and not allowed to be enabled"); } if (this.features.matterScheduleConfiguration) { logger.warn("MatterScheduleConfiguration feature is not yet implemented. Please do not activate it"); } if (!this.features.presets && !this.features.matterScheduleConfiguration) { this.atomicRequest = Behavior.unimplemented; } // Initialize persisted presets from defaults if not already set const options = this.endpoint.behaviors.optionsFor(ThermostatBaseServer) as | { presets: Thermostat.Preset[] | undefined; } | undefined; if (this.features.presets && this.state.persistedPresets === undefined) { this.state.persistedPresets = options?.presets ?? []; } // Add this check because we currently do not have a max in Schema and might have old invalid max values if (this.state.minSetpointDeadBand > 127) { this.state.minSetpointDeadBand = 20; } if (this.state.minSetpointDeadBand < 0 || this.state.minSetpointDeadBand > 127) { throw new ImplementationError("minSetpointDeadBand is out of valid range 0..127"); } const node = Node.forEndpoint(this.endpoint); this.reactTo(node.lifecycle.online, this.#nodeOnline); // Initialize all the validation and logic handling this.#setupValidations(); this.#setupModeHandling(); this.#setupThermostatLogic(); this.#setupPresets(); // We store these values internally because we need to restore them after any write try this.internal.minSetpointDeadBand = this.state.minSetpointDeadBand; this.internal.controlSequenceOfOperation = this.state.controlSequenceOfOperation; } #nodeOnline() { // TODO Also react to structure changes to add/remove endpoints that are being added or removed but might be used // for temperature or occupancy sensing this.#setupTemperatureMeasurementIntegration(); this.#setupOccupancyIntegration(); } /** * The default implementation of the SetpointRaiseLower command. It handles all validation and setpoint adjustments * required by the Matter specification. This method only changes the Occupied setpoints. */ override setpointRaiseLower({ mode, amount }: Thermostat.SetpointRaiseLowerRequest) { if (mode === Thermostat.SetpointRaiseLowerMode.Heat && !this.features.heating) { throw new StatusResponse.InvalidCommandError( "Heating feature is not supported but Heat mode was requested", ); } if (mode === Thermostat.SetpointRaiseLowerMode.Cool && !this.features.cooling) { throw new StatusResponse.InvalidCommandError( "Cooling feature is not supported but Cool mode was requested", ); } amount *= 10; // Convert to same base as the setpoints if (mode === Thermostat.SetpointRaiseLowerMode.Both) { if (this.features.heating && this.features.cooling) { let desiredCoolingSetpoint = this.coolingSetpoint + amount; const coolLimit = desiredCoolingSetpoint - this.#clampSetpointToLimits("Cool", desiredCoolingSetpoint); let desiredHeatingSetpoint = this.heatingSetpoint + amount; const heatLimit = desiredHeatingSetpoint - this.#clampSetpointToLimits("Heat", desiredHeatingSetpoint); if (coolLimit !== 0 || heatLimit !== 0) { if (Math.abs(coolLimit) <= Math.abs(heatLimit)) { // We are limited by the Heating Limit desiredHeatingSetpoint = desiredHeatingSetpoint - heatLimit; desiredCoolingSetpoint = desiredCoolingSetpoint - heatLimit; } else { // We are limited by Cooling Limit desiredHeatingSetpoint = desiredHeatingSetpoint - coolLimit; desiredCoolingSetpoint = desiredCoolingSetpoint - coolLimit; } } this.coolingSetpoint = desiredCoolingSetpoint; this.heatingSetpoint = desiredHeatingSetpoint; } else if (this.features.cooling) { this.coolingSetpoint = this.#clampSetpointToLimits("Cool", this.coolingSetpoint + amount); } else { this.heatingSetpoint = this.#clampSetpointToLimits("Heat", this.heatingSetpoint + amount); } return; } if (mode === Thermostat.SetpointRaiseLowerMode.Cool) { const desiredCoolingSetpoint = this.#clampSetpointToLimits("Cool", this.coolingSetpoint + amount); if (this.features.autoMode) { let heatingSetpoint = this.heatingSetpoint; if (desiredCoolingSetpoint - heatingSetpoint < this.setpointDeadBand) { // We are limited by the Heating Setpoint heatingSetpoint = desiredCoolingSetpoint - this.setpointDeadBand; if (heatingSetpoint === this.#clampSetpointToLimits("Heat", heatingSetpoint)) { // Desired cooling setpoint is enforceable // Set the new cooling and heating setpoints this.heatingSetpoint = heatingSetpoint; } else { throw new StatusResponse.InvalidCommandError( "Could Not adjust heating setpoint to maintain dead band!", ); } } } this.coolingSetpoint = desiredCoolingSetpoint; return; } if (mode === Thermostat.SetpointRaiseLowerMode.Heat) { const desiredHeatingSetpoint = this.#clampSetpointToLimits("Heat", this.heatingSetpoint + amount); if (this.features.autoMode) { let coolingSetpoint = this.coolingSetpoint; if (coolingSetpoint - desiredHeatingSetpoint < this.setpointDeadBand) { // We are limited by the Cooling Setpoint coolingSetpoint = desiredHeatingSetpoint + this.setpointDeadBand; if (coolingSetpoint === this.#clampSetpointToLimits("Cool", coolingSetpoint)) { // Desired cooling setpoint is enforceable // Set the new cooling and heating setpoints this.coolingSetpoint = coolingSetpoint; } else { throw new StatusResponse.InvalidCommandError( "Could Not adjust cooling setpoint to maintain dead band!", ); } } } this.heatingSetpoint = desiredHeatingSetpoint; return; } throw new StatusResponse.InvalidCommandError(`Unsupported SetpointRaiseLowerMode ${mode}`); } /** * Performs basic validation and sets the active preset handle when valid. * This fulfills the basic requirements of the SetActivePresetRequest matter command. Use this method if you need * to override setActivePresetRequest to ensure compliance. */ protected handleSetActivePresetRequest({ presetHandle }: Thermostat.SetActivePresetRequest) { let preset: Thermostat.Preset | undefined = undefined; if (presetHandle !== null) { preset = this.state.persistedPresets?.find( p => p.presetHandle !== null && Bytes.areEqual(p.presetHandle, presetHandle), ); if (preset === undefined) { throw new StatusResponse.InvalidCommandError("Requested PresetHandle not found"); } } logger.info(`Setting active preset handle to`, presetHandle); this.state.activePresetHandle = presetHandle; return preset; } /** * This default implementation of the SetActivePresetRequest command handler sets the active preset and * (additionally to specification requirements!) adjusts the setpoints to the preset values if defined. * * If you do not want this behavior, you can override this method but should call handleSetActivePresetRequest to * ensure compliance with the specification. */ override setActivePresetRequest({ presetHandle }: Thermostat.SetActivePresetRequest) { const preset = this.handleSetActivePresetRequest({ presetHandle }); if (preset !== undefined) { const { heatingSetpoint, coolingSetpoint } = preset; if (this.features.heating && heatingSetpoint !== null && heatingSetpoint !== undefined) { this.heatingSetpoint = this.#clampSetpointToLimits("Heat", heatingSetpoint); } if (this.features.cooling && coolingSetpoint !== null && coolingSetpoint !== undefined) { this.coolingSetpoint = this.#clampSetpointToLimits("Cool", coolingSetpoint); } } } /** Determines if the given context is from a command */ #isCommandContext(context: ActionContext) { return "command" in context && context.command; } /** * Whether the thermostat is currently considered occupied * Uses the occupancy state if the feature is supported, otherwise always true */ protected get occupied() { return this.features.occupancy ? (this.state.occupancy?.occupied ?? true) : true; } /** The current heating setpoint depending on occupancy */ protected get heatingSetpoint() { if (this.occupied) { return this.state.occupiedHeatingSetpoint; } return this.state.unoccupiedHeatingSetpoint; } protected set heatingSetpoint(value: number) { if (this.occupied) { this.state.occupiedHeatingSetpoint = value; } else { this.state.unoccupiedHeatingSetpoint = value; } } /** The current cooling setpoint depending on occupancy */ protected get coolingSetpoint() { if (this.occupied) { return this.state.occupiedCoolingSetpoint; } return this.state.unoccupiedCoolingSetpoint; } protected set coolingSetpoint(value: number) { if (this.occupied) { this.state.occupiedCoolingSetpoint = value; } else { this.state.unoccupiedCoolingSetpoint = value; } } /** Setup basic Thermostat state and logic */ #setupThermostatLogic() { if (this.state.temperatureSetpointHold !== undefined) { // When we support temperature setpoint hold, ensure related states are initialized if (this.state.temperatureSetpointHoldDuration === undefined) { this.state.temperatureSetpointHoldDuration = null; } // TODO Add support for correct Time handling, leave disabled for now if (this.state.setpointHoldExpiryTimestamp === undefined) { //this.agent.asLocalActor(() => { // this.state.setpointHoldExpiryTimestamp = null; //}); } else { logger.warn( "Handling for setpointHoldExpiryTimestamp is not yet implemented. To use this attribute you need to install the needed logic yourself", ); } //this.maybeReactTo(this.events.temperatureSetpointHold$Changed, this.#handleTemperatureSetpointHoldChange); } } // TODO Add when we adjusted the epoch-s handling to be correct /*#handleTemperatureSetpointHoldChange(newValue: Thermostat.TemperatureSetpointHold) { if (newValue === Thermostat.TemperatureSetpointHold.SetpointHoldOn) { if ( this.state.temperatureSetpointHoldDuration !== null && this.state.temperatureSetpointHoldDuration! > 0 ) { // TODO: convert to use of Seconds and such and real UTC time // Also requires adjustment in encoding/decoding of the attribute const nowUtc = Time.nowMs - 946_684_800_000; // Still not really UTC, but ok for now this.state.setpointHoldExpiryTimestamp = Math.floor( nowUtc / 1000 + this.state.temperatureSetpointHoldDuration! * 60, ); } } else { this.state.setpointHoldExpiryTimestamp = null; } }*/ /** Whether heating is allowed in the current ControlSequenceOfOperation and features */ protected get heatingAllowed() { return ( this.features.heating && ![ Thermostat.ControlSequenceOfOperation.CoolingOnly, Thermostat.ControlSequenceOfOperation.CoolingAndHeatingWithReheat, ].includes(this.internal.controlSequenceOfOperation) ); } /** Whether cooling is allowed in the current ControlSequenceOfOperation and features */ protected get coolingAllowed() { return ( this.features.cooling && ![ Thermostat.ControlSequenceOfOperation.HeatingOnly, Thermostat.ControlSequenceOfOperation.HeatingWithReheat, ].includes(this.internal.controlSequenceOfOperation) ); } /** * Adjust the running mode of the thermostat based on the new system mode when the thermostatRunningMode is supported */ protected adjustRunningMode(newState: Thermostat.ThermostatRunningMode) { if (this.state.thermostatRunningMode === undefined) { return; } switch (newState) { case Thermostat.ThermostatRunningMode.Heat: if (!this.heatingAllowed) { throw new ImplementationError("Heating is not allowed in the current ControlSequenceOfOperation"); } break; case Thermostat.ThermostatRunningMode.Cool: if (!this.coolingAllowed) { throw new ImplementationError("Cooling is not allowed in the current ControlSequenceOfOperation"); } break; } this.agent.asLocalActor(() => { this.state.thermostatRunningMode = newState; }); } /** * Setup integration with TemperatureMeasurement cluster or external temperature state and intialize internal * localTemperature state. */ #setupTemperatureMeasurementIntegration() { const preferRemoteTemperature = !!this.state.remoteSensing?.localTemperature; if (this.features.localTemperatureNotExposed) { if (preferRemoteTemperature) { throw new ImplementationError( "RemoteSensing cannot be set to LocalTemperature when LocalTemperatureNotExposed feature is enabled", ); } logger.debug("LocalTemperatureNotExposed feature is enabled, ignoring local temperature measurement"); this.state.localTemperature = null; } let localTemperature = null; const localTempEndpoint = this.state.localIndoorTemperatureMeasurementEndpoint; if (!preferRemoteTemperature && localTempEndpoint !== undefined) { const endpoints = this.env.get(ServerNode).endpoints; const endpoint = endpoints.has(localTempEndpoint) ? endpoints.for(localTempEndpoint) : undefined; if (endpoint !== undefined && endpoint.behaviors.has(TemperatureMeasurementServer)) { logger.debug( `Using existing TemperatureMeasurement cluster on endpoint #${localTempEndpoint} for local temperature measurement`, ); if (this.state.externalMeasuredIndoorTemperature !== undefined) { logger.warn( "Both local TemperatureMeasurement cluster and externalMeasuredIndoorTemperature state are set, using local cluster", ); } this.reactTo( endpoint.eventsOf(TemperatureMeasurementServer).measuredValue$Changed, this.#handleMeasuredTemperatureChange, ); localTemperature = endpoint.stateOf(TemperatureMeasurementServer).measuredValue; } else { logger.warn( `No TemperatureMeasurement cluster found on endpoint #${localTempEndpoint}, falling back to externalMeasuredIndoorTemperature state if set`, ); } } else { if (this.state.externalMeasuredIndoorTemperature === undefined) { if (this.state.localTemperatureCalibration !== undefined) { logger.warn( "No local TemperatureMeasurement cluster available, externalMeasuredIndoorTemperature state not set but localTemperatureCalibration is used: Ensure to correctly consider the calibration when updating the localTemperature value", ); } } else { logger.info("Using measured temperature via externalMeasuredIndoorTemperature state"); localTemperature = this.state.externalMeasuredIndoorTemperature ?? null; } this.reactTo(this.events.externalMeasuredIndoorTemperature$Changed, this.#handleMeasuredTemperatureChange); } if (localTemperature !== null) { this.#handleMeasuredTemperatureChange(localTemperature); // and initialize } } /** * Handles changes to the measured temperature, applies calibration and update internal and official state. */ #handleMeasuredTemperatureChange(temperature: number | null) { if (temperature !== null && this.state.localTemperatureCalibration !== undefined) { temperature += this.state.localTemperatureCalibration * 10; } // When localTemperatureNotExposed feature is enabled, we do not update the attribute because it needs to stay null if (!this.features.localTemperatureNotExposed) { this.state.localTemperature = temperature; } // When the temperature changes, we always update the internal localTemperature and emit event const oldTemperature = this.internal.localTemperature; if (temperature !== null && oldTemperature !== temperature) { this.internal.localTemperature = temperature; this.events.calibratedTemperature$Changed.emit(temperature, oldTemperature, this.context); } } /** * Setup integration with OccupancySensing cluster or external occupancy state and initialize internal occupancy * state. */ #setupOccupancyIntegration() { if (!this.features.occupancy) { return; } let currentOccupancy = true; const preferRemoteOccupancy = !!this.state.remoteSensing?.occupancy; const localOccupancyEndpoint = this.state.localOccupancyMeasurementEndpoint; if (!preferRemoteOccupancy && localOccupancyEndpoint !== undefined) { const endpoints = this.env.get(ServerNode).endpoints; const endpoint = endpoints.has(localOccupancyEndpoint) ? endpoints.for(localOccupancyEndpoint) : undefined; if (endpoint !== undefined && endpoint.behaviors.has(OccupancySensingServer)) { logger.debug( `Using existing OccupancySensing cluster on endpoint ${localOccupancyEndpoint} for local occupancy sensing`, ); if (this.state.externallyMeasuredOccupancy !== undefined) { logger.warn( "Both local OccupancySensing cluster and externallyMeasuredOccupancy state are set, using local cluster", ); } this.reactTo(endpoint.eventsOf(OccupancySensingServer).occupancy$Changed, this.#handleOccupancyChange); currentOccupancy = !!endpoint.stateOf(OccupancySensingServer).occupancy.occupied; } else { logger.warn( `No OccupancySensing cluster found on endpoint ${localOccupancyEndpoint}, falling back to externallyMeasuredOccupancy state if set`, ); } } else { if (this.state.externallyMeasuredOccupancy === undefined) { logger.warn( "No local OccupancySensing cluster available and externallyMeasuredOccupancy state not set", ); } else { logger.info("Using occupancy via externallyMeasuredOccupancy state"); currentOccupancy = this.state.externallyMeasuredOccupancy; } this.reactTo(this.events.externallyMeasuredOccupancy$Changed, this.#handleExternalOccupancyChange); } this.#handleExternalOccupancyChange(currentOccupancy); // and initialize } #handleExternalOccupancyChange(newValue: boolean) { this.state.occupancy = { occupied: newValue }; } #handleOccupancyChange(newValue: OccupancySensing.Occupancy) { this.state.occupancy = newValue; } /** Setup all validations for the Thermostat behavior */ #setupValidations() { // Validate existing values to match the constraints at initialization this.#assertUserSetpointLimits("HeatSetpointLimit"); this.#assertUserSetpointLimits("CoolSetpointLimit"); this.#clampSetpointToLimits("Heat", this.state.occupiedHeatingSetpoint); this.#clampSetpointToLimits("Heat", this.state.unoccupiedHeatingSetpoint); this.#clampSetpointToLimits("Cool", this.state.occupiedCoolingSetpoint); this.#clampSetpointToLimits("Cool", this.state.unoccupiedCoolingSetpoint); // Setup reactions for validations on changes this.maybeReactTo(this.events.absMinHeatSetpointLimit$Changing, this.#assertAbsMinHeatSetpointLimitChanging); this.maybeReactTo(this.events.minHeatSetpointLimit$Changing, this.#assertMinHeatSetpointLimitChanging); this.maybeReactTo(this.events.maxHeatSetpointLimit$Changing, this.#assertMaxHeatSetpointLimitChanging); this.maybeReactTo(this.events.absMaxHeatSetpointLimit$Changing, this.#assertAbsMaxHeatSetpointLimitChanging); this.maybeReactTo(this.events.absMinCoolSetpointLimit$Changing, this.#assertAbsMinCoolSetpointLimitChanging); this.maybeReactTo(this.events.minCoolSetpointLimit$Changing, this.#assertMinCoolSetpointLimitChanging); this.maybeReactTo(this.events.maxCoolSetpointLimit$Changing, this.#assertMaxCoolSetpointLimitChanging); this.maybeReactTo(this.events.absMaxCoolSetpointLimit$Changing, this.#assertAbsMaxCoolSetpointLimitChanging); this.maybeReactTo(this.events.occupiedHeatingSetpoint$Changing, this.#assertOccupiedHeatingSetpointChanging); this.maybeReactTo( this.events.unoccupiedHeatingSetpoint$Changing, this.#assertUnoccupiedHeatingSetpointChanging, ); this.maybeReactTo(this.events.occupiedCoolingSetpoint$Changing, this.#assertOccupiedCoolingSetpointChanging); this.maybeReactTo( this.events.unoccupiedCoolingSetpoint$Changing, this.#assertUnoccupiedCoolingSetpointChanging, ); this.maybeReactTo(this.events.remoteSensing$Changing, this.#assertRemoteSensingChanging); // For backwards compatibility, this attribute is optionally writeable. However, any // writes to this attribute SHALL be silently ignored. So we just revert any changes. this.maybeReactTo(this.events.minSetpointDeadBand$Changing, this.#ensureMinSetpointDeadBandNotWritable); this.reactTo( this.events.controlSequenceOfOperation$Changing, this.#ensureControlSequenceOfOperationNotWritable, ); this.reactTo(this.events.systemMode$Changing, this.#assertSystemModeChanging); this.maybeReactTo(this.events.thermostatRunningMode$Changing, this.#assertThermostatRunningModeChanging); } #assertThermostatRunningModeChanging(newRunningMode: Thermostat.ThermostatRunningMode) { const forbiddenRunningModes = new Array(); // We use the internal value here to ensure not a temporarily invalid state during writes switch (this.internal.controlSequenceOfOperation) { case Thermostat.ControlSequenceOfOperation.CoolingOnly: case Thermostat.ControlSequenceOfOperation.CoolingAndHeatingWithReheat: forbiddenRunningModes.push(Thermostat.ThermostatRunningMode.Heat); break; case Thermostat.ControlSequenceOfOperation.HeatingOnly: case Thermostat.ControlSequenceOfOperation.HeatingWithReheat: forbiddenRunningModes.push(Thermostat.ThermostatRunningMode.Cool); break; } if (forbiddenRunningModes.includes(newRunningMode)) { throw new StatusResponse.ConstraintErrorError( `ThermostatRunningMode ${Thermostat.ThermostatRunningMode[newRunningMode]} is not allowed with ControlSequenceOfOperation ${ Thermostat.ControlSequenceOfOperation[this.internal.controlSequenceOfOperation] }`, ); } } #assertSystemModeChanging(newMode: Thermostat.SystemMode) { const forbiddenModes = new Array(); switch (this.internal.controlSequenceOfOperation) { case Thermostat.ControlSequenceOfOperation.CoolingOnly: case Thermostat.ControlSequenceOfOperation.CoolingAndHeatingWithReheat: forbiddenModes.push(Thermostat.SystemMode.Heat, Thermostat.SystemMode.EmergencyHeat); break; case Thermostat.ControlSequenceOfOperation.HeatingOnly: case Thermostat.ControlSequenceOfOperation.HeatingWithReheat: forbiddenModes.push(Thermostat.SystemMode.Cool, Thermostat.SystemMode.Precooling); break; } if (forbiddenModes.includes(newMode)) { throw new StatusResponse.ConstraintErrorError( `SystemMode ${Thermostat.SystemMode[newMode]} is not allowed with ControlSequenceOfOperation ${ Thermostat.ControlSequenceOfOperation[this.internal.controlSequenceOfOperation] }`, ); } } /** Attribute is not writable, revert any changes */ #ensureControlSequenceOfOperationNotWritable() { this.state.controlSequenceOfOperation = this.internal.controlSequenceOfOperation; } /** Attribute is not writable, revert any changes, but also ensure proper errors when write try was invalid */ #ensureMinSetpointDeadBandNotWritable(value: number) { if (value < 0 || value > 127) { throw new StatusResponse.ConstraintErrorError("MinSetpointDeadBand is out of valid range 0..127"); } this.state.minSetpointDeadBand = this.internal.minSetpointDeadBand; } #assertRemoteSensingChanging(remoteSensing: Thermostat.RemoteSensing) { if (this.features.localTemperatureNotExposed && remoteSensing.localTemperature) { throw new StatusResponse.ConstraintErrorError( "LocalTemperature is not exposed, so RemoteSensing cannot be set to LocalTemperature", ); } } #assertUnoccupiedCoolingSetpointChanging(setpoint: number, _old: number, context: ActionContext) { const direct = !this.#isCommandContext(context); if (direct) { this.#assertSetpointWithinLimits("Cool", "Unoccupied", setpoint); } this.#reconcileSetpoints(new Set(["unoccupiedCoolingSetpoint"])); if (direct && this.features.presets && this.state.activePresetHandle !== null && !this.occupied) { this.agent.asLocalActor(() => { this.state.activePresetHandle = null; }); } } #assertUnoccupiedHeatingSetpointChanging(setpoint: number, _old: number, context: ActionContext) { const direct = !this.#isCommandContext(context); if (direct) { this.#assertSetpointWithinLimits("Heat", "Unoccupied", setpoint); } this.#reconcileSetpoints(new Set(["unoccupiedHeatingSetpoint"])); if (direct && this.features.presets && this.state.activePresetHandle !== null && !this.occupied) { this.agent.asLocalActor(() => { this.state.activePresetHandle = null; }); } } #assertAbsMaxCoolSetpointLimitChanging(absMax: number) { this.#assertUserSetpointLimits("CoolSetpointLimit", { absMax }); } #assertMaxCoolSetpointLimitChanging(max: number) { this.#assertLimitWithinAbs("Cool", max); this.#reconcileSetpoints(new Set(["maxCoolSetpointLimit"])); } #assertMinCoolSetpointLimitChanging(min: number) { this.#assertLimitWithinAbs("Cool", min); this.#reconcileSetpoints(new Set(["minCoolSetpointLimit"])); } #assertAbsMinCoolSetpointLimitChanging(absMin: number) { this.#assertUserSetpointLimits("CoolSetpointLimit", { absMin }); } #assertAbsMaxHeatSetpointLimitChanging(absMax: number) { this.#assertUserSetpointLimits("HeatSetpointLimit", { absMax }); } #assertMaxHeatSetpointLimitChanging(max: number) { this.#assertLimitWithinAbs("Heat", max); this.#reconcileSetpoints(new Set(["maxHeatSetpointLimit"])); } #assertMinHeatSetpointLimitChanging(min: number) { this.#assertLimitWithinAbs("Heat", min); this.#reconcileSetpoints(new Set(["minHeatSetpointLimit"])); } #assertAbsMinHeatSetpointLimitChanging(absMin: number) { this.#assertUserSetpointLimits("HeatSetpointLimit", { absMin }); } #assertOccupiedCoolingSetpointChanging(setpoint: number, _old: number, context: ActionContext) { const direct = !this.#isCommandContext(context); if (direct) { this.#assertSetpointWithinLimits("Cool", "Occupied", setpoint); } this.#reconcileSetpoints(new Set(["occupiedCoolingSetpoint"])); if (direct && this.features.presets && this.state.activePresetHandle !== null && this.occupied) { this.agent.asLocalActor(() => { this.state.activePresetHandle = null; }); } } #assertOccupiedHeatingSetpointChanging(setpoint: number, _old: number, context: ActionContext) { const direct = !this.#isCommandContext(context); if (direct) { this.#assertSetpointWithinLimits("Heat", "Occupied", setpoint); } this.#reconcileSetpoints(new Set(["occupiedHeatingSetpoint"])); if (direct && this.features.presets && this.state.activePresetHandle !== null && this.occupied) { this.agent.asLocalActor(() => { this.state.activePresetHandle = null; }); } } /** * The current mode the thermostat is considered to be in based on local temperature and setpoints */ protected get temperatureConsideration(): "belowTarget" | "onTarget" | "aboveTarget" | undefined { const localTemp = this.internal.localTemperature; if (localTemp === null) { return undefined; } const minSetPointDeadband = this.setpointDeadBand; const heatingSetpoint = this.heatingSetpoint; const coolingSetpoint = this.coolingSetpoint; switch (this.state.systemMode) { case Thermostat.SystemMode.Heat: if (localTemp < heatingSetpoint) { return "belowTarget"; } if (localTemp > coolingSetpoint) { return "onTarget"; } break; case Thermostat.SystemMode.Cool: if (localTemp < heatingSetpoint) { return "onTarget"; } if (localTemp > coolingSetpoint) { return "aboveTarget"; } break; case Thermostat.SystemMode.Auto: if (localTemp < heatingSetpoint - minSetPointDeadband) { return "belowTarget"; } if (localTemp > coolingSetpoint + minSetPointDeadband) { return "aboveTarget"; } break; } return "onTarget"; } get #heatDefaults() { return { absMin: 700, absMax: 3000, }; } get #coolDefaults() { return { absMin: 1600, absMax: 3200, }; } /** * Used to validate generically that user configurable limits must be within device limits follow: * * AbsMinHeatSetpointLimit <= MinHeatSetpointLimit <= MaxHeatSetpointLimit <= AbsMaxHeatSetpointLimit * * AbsMinCoolSetpointLimit <= MinCoolSetpointLimit <= MaxCoolSetpointLimit <= AbsMaxCoolSetpointLimit * Values not provided are taken from the state */ #assertUserSetpointLimits( scope: "HeatSetpointLimit" | "CoolSetpointLimit", details: { absMin?: number; min?: number; max?: number; absMax?: number } = {}, ) { const defaults = scope === "HeatSetpointLimit" ? this.#heatDefaults : this.#coolDefaults; const { absMin = this.state[`absMin${scope}`] ?? defaults.absMin, min = this.state[`min${scope}`] ?? defaults.absMin, max = this.state[`max${scope}`] ?? defaults.absMax, absMax = this.state[`absMax${scope}`] ?? defaults.absMax, } = details; logger.debug( `Validating user setpoint limits for ${scope}: absMin=${absMin}, min=${min}, max=${max}, absMax=${absMax}`, ); if (absMin > min) { throw new StatusResponse.ConstraintErrorError( `absMin${scope} (${absMin}) must be less than or equal to min${scope} (${min})`, ); } if (min > max) { throw new StatusResponse.ConstraintErrorError( `min${scope} (${min}) must be less than or equal to max${scope} (${max})`, ); } if (max > absMax) { throw new StatusResponse.ConstraintErrorError( `max${scope} (${max}) must be less than or equal to absMax${scope} (${absMax})`, ); } } get heatSetpointMinimum() { const absMin = this.state.absMinHeatSetpointLimit ?? this.#heatDefaults.absMin; const min = this.state.minHeatSetpointLimit ?? this.#heatDefaults.absMin; return Math.max(min, absMin); } get heatSetpointMaximum() { const absMax = this.state.absMaxHeatSetpointLimit ?? this.#heatDefaults.absMax; const max = this.state.maxHeatSetpointLimit ?? this.#heatDefaults.absMax; return Math.min(max, absMax); } get coolSetpointMinimum() { const absMin = this.state.absMinCoolSetpointLimit ?? this.#coolDefaults.absMin; const min = this.state.minCoolSetpointLimit ?? this.#coolDefaults.absMin; return Math.max(min, absMin); } get coolSetpointMaximum() { const absMax = this.state.absMaxCoolSetpointLimit ?? this.#coolDefaults.absMax; const max = this.state.maxCoolSetpointLimit ?? this.#coolDefaults.absMax; return Math.min(max, absMax); } get setpointDeadBand() { return this.features.autoMode ? this.internal.minSetpointDeadBand * 10 : 0; } #clampSetpointToLimits(scope: "Heat" | "Cool", setpoint: number): number { const limitMin = scope === "Heat" ? this.heatSetpointMinimum : this.coolSetpointMinimum; const limitMax = scope === "Heat" ? this.heatSetpointMaximum : this.coolSetpointMaximum; const result = cropValueRange(setpoint, limitMin, limitMax); if (result !== setpoint) { logger.debug( `${scope} setpoint (${setpoint}) is out of limits [${limitMin}, ${limitMax}], clamping to ${result}`, ); } return result; } /** * Used to validate that Setpoints must be within user configurable limits */ #assertSetpointWithinLimits(scope: "Heat" | "Cool", type: "Occupied" | "Unoccupied", setpoint: number) { const limitMin = scope === "Heat" ? this.heatSetpointMinimum : this.coolSetpointMinimum; const limitMax = scope === "Heat" ? this.heatSetpointMaximum : this.coolSetpointMaximum; if (limitMin !== undefined && setpoint < limitMin) { throw new StatusResponse.ConstraintErrorError( `${scope}${type}Setpoint (${setpoint}) must be greater than or equal to min${scope}SetpointLimit (${limitMin})`, ); } if (limitMax !== undefined && setpoint > limitMax) { throw new StatusResponse.ConstraintErrorError( `${scope}${type}Setpoint (${setpoint}) must be less than or equal to max${scope}SetpointLimit (${limitMax})`, ); } } /** * A written user setpoint limit outside its own mode's absolute range is a hard CONSTRAINT_ERROR (CHIP * `ChangeLimits`); everything else is accepted and reconciled. */ #assertLimitWithinAbs(scope: "Heat" | "Cool", value: number) { const defaults = scope === "Heat" ? this.#heatDefaults : this.#coolDefaults; const absMin = this.state[`absMin${scope}SetpointLimit`] ?? defaults.absMin; const absMax = this.state[`absMax${scope}SetpointLimit`] ?? defaults.absMax; if (value < absMin || value > absMax) { throw new StatusResponse.ConstraintErrorError( `${scope}SetpointLimit (${value}) must be within absolute limits [${absMin}, ${absMax}]`, ); } } /** Absolute [min, max] for a mode, falling back to the Matter defaults when unset. */ #absLimits(scope: "Heat" | "Cool") { const defaults = scope === "Heat" ? this.#heatDefaults : this.#coolDefaults; return { min: this.state[`absMin${scope}SetpointLimit`] ?? defaults.absMin, max: this.state[`absMax${scope}SetpointLimit`] ?? defaults.absMax, }; } /** Effective user limit: the override if set, else the corresponding absolute bound (CHIP OptionalSetpoint). */ #userLimit(scope: "Heat" | "Cool", bound: "min" | "max") { const abs = this.#absLimits(scope); return this.state[`${bound}${scope}SetpointLimit`] ?? (bound === "min" ? abs.min : abs.max); } /** * Mirror of CHIP `Setpoints::Valid()`: the invariant that {@link #reconcileSetpoints} restores. Checks, per * supported mode, that absolute and user limits are ordered and nested and that setpoints sit within the user * limits; and, with AutoMode, that heat/cool limits and setpoints maintain the deadband. */ #setpointsValid(): boolean { const dead = this.setpointDeadBand; for (const scope of ["Heat", "Cool"] as const) { if (scope === "Heat" ? !this.features.heating : !this.features.cooling) { continue; } const abs = this.#absLimits(scope); if (abs.min > abs.max) { return false; } const min = this.#userLimit(scope, "min"); const max = this.#userLimit(scope, "max"); if (min > max || min < abs.min || max > abs.max) { return false; } for (const type of ["occupied", "unoccupied"] as const) { if (type === "unoccupied" && !this.features.occupancy) { continue; } const value = this.state[`${type}${scope}ingSetpoint`]; if (value !== undefined && (value < min || value > max)) { return false; } } } if (this.features.autoMode) { if (this.#userLimit("Cool", "max") - this.#userLimit("Heat", "max") < dead) { return false; } if (this.#userLimit("Cool", "min") - this.#userLimit("Heat", "min") < dead) { return false; } for (const type of ["occupied", "unoccupied"] as const) { if (type === "unoccupied" && !this.features.occupancy) { continue; } const heat = this.state[`${type}HeatingSetpoint`]; const cool = this.state[`${type}CoolingSetpoint`]; if (heat !== undefined && cool !== undefined && cool - heat < dead) { return false; } } } return true; } /** * Single-pass reconciliation of the setpoint/limit invariants after a write, mirroring CHIP `Setpoints::Fix()` * (Thermostat cluster spec ยง4.3.11.15-18): user-limit ordering, then AutoMode limit deadband, then setpoint range * and deadband. Adjustments move the coupled (unwritten) value; when they cannot be resolved within the absolute * bounds the state stays invalid and CONSTRAINT_ERROR is raised. `changed` holds the state key(s) the client * touched so the reconciliation preserves the client's intent and moves the other side. */ #reconcileSetpoints(changed: Set) { if (this.#setpointsValid()) { return; } if (this.features.heating) { this.#fixUserLimits("Heat", changed); } if (this.features.cooling) { this.#fixUserLimits("Cool", changed); } if (this.features.autoMode) { this.#fixLimitDeadband("max", changed); this.#fixLimitDeadband("min", changed); } this.#fixSetpointRange("occupied", changed); if (this.features.occupancy) { this.#fixSetpointRange("unoccupied", changed); } if (!this.#setpointsValid()) { throw new StatusResponse.ConstraintErrorError( "Thermostat setpoints could not be reconciled within the configured limits", ); } } /** CHIP `FixUserLimits`: pull user limits inside the absolute range, then repair min <= max ordering. */ #fixUserLimits(scope: "Heat" | "Cool", changed: Set) { const abs = this.#absLimits(scope); if (abs.min > abs.max) { return; } const minKey = `min${scope}SetpointLimit` as const; const maxKey = `max${scope}SetpointLimit` as const; const min = this.state[minKey]; if (min !== undefined && (min < abs.min || min > abs.max)) { this.state[minKey] = abs.min; } const max = this.state[maxKey]; if (max !== undefined && (max < abs.min || max > abs.max)) { this.state[maxKey] = abs.max; } if (this.#userLimit(scope, "min") > this.#userLimit(scope, "max")) { if (changed.has(minKey) && this.state[maxKey] !== undefined) { this.state[maxKey] = this.#userLimit(scope, "min"); } else if (changed.has(maxKey) && this.state[minKey] !== undefined) { this.state[minKey] = this.#userLimit(scope, "max"); } } } /** * CHIP `FixUserLimitDeadband`: restore `cool - heat >= deadband` for a limit pair by moving the coupled * (unwritten) mode's limit; if that would leave the coupled mode's absolute range, pin it to the bound and pull * the written mode's limit back instead. */ #fixLimitDeadband(bound: "min" | "max", changed: Set) { const dead = this.setpointDeadBand; const heat = this.#userLimit("Heat", bound); const cool = this.#userLimit("Cool", bound); if (cool - heat >= dead) { return; } const heatKey = `${bound}HeatSetpointLimit` as const; const coolKey = `${bound}CoolSetpointLimit` as const; const absCool = this.#absLimits("Cool"); const absHeat = this.#absLimits("Heat"); if (changed.has("minHeatSetpointLimit") || changed.has("maxHeatSetpointLimit")) { const newCool = heat + dead; if (newCool >= absCool.min && newCool <= absCool.max) { this.state[coolKey] = newCool; } else { const pinned = bound === "min" ? absCool.min : absCool.max; this.state[coolKey] = pinned; this.state[heatKey] = pinned - dead; } } else if (changed.has("minCoolSetpointLimit") || changed.has("maxCoolSetpointLimit")) { const newHeat = cool - dead; if (newHeat >= absHeat.min && newHeat <= absHeat.max) { this.state[heatKey] = newHeat; } else { const pinned = bound === "min" ? absHeat.min : absHeat.max; this.state[heatKey] = pinned; this.state[coolKey] = pinned + dead; } } } /** * CHIP `FixRange`: clamp a setpoint range back inside the (possibly changed) user limits, then restore the * AutoMode setpoint deadband by moving the coupled setpoint; if that leaves the user limits, pin it to the user * limit and pull the touched setpoint back instead. */ #fixSetpointRange(type: "occupied" | "unoccupied", changed: Set) { const dead = this.setpointDeadBand; const heatKey = `${type}HeatingSetpoint` as const; const coolKey = `${type}CoolingSetpoint` as const; const uHeatMin = this.#userLimit("Heat", "min"); const uHeatMax = this.#userLimit("Heat", "max"); const uCoolMin = this.#userLimit("Cool", "min"); const uCoolMax = this.#userLimit("Cool", "max"); if (this.features.heating && this.state[heatKey] !== undefined) { this.state[heatKey] = cropValueRange(this.state[heatKey], uHeatMin, uHeatMax); } if (this.features.cooling && this.state[coolKey] !== undefined) { this.state[coolKey] = cropValueRange(this.state[coolKey], uCoolMin, uCoolMax); } if (!this.features.autoMode) { return; } const heat = this.state[heatKey]; const cool = this.state[coolKey]; if (heat === undefined || cool === undefined || cool - heat >= dead) { return; } if (changed.has(heatKey) || changed.has("minHeatSetpointLimit") || changed.has("maxHeatSetpointLimit")) { const newCool = heat + dead; if (newCool >= uCoolMin && newCool <= uCoolMax) { this.state[coolKey] = newCool; } else { this.state[coolKey] = uCoolMax; this.state[heatKey] = uCoolMax - dead; } } else if (changed.has(coolKey) || changed.has("minCoolSetpointLimit") || changed.has("maxCoolSetpointLimit")) { const newHeat = cool - dead; if (newHeat >= uHeatMin && newHeat <= uHeatMax) { this.state[heatKey] = newHeat; } else { this.state[heatKey] = uHeatMin; this.state[coolKey] = uHeatMin + dead; } } } #setupModeHandling() { this.reactTo(this.events.systemMode$Changed, this.#handleSystemModeChange); this.maybeReactTo(this.events.thermostatRunningMode$Changed, this.#handleThermostatRunningModeChange); if (this.state.useAutomaticModeManagement && this.state.thermostatRunningMode !== undefined) { this.reactTo(this.events.calibratedTemperature$Changed, this.#handleTemperatureChangeForMode); this.#handleTemperatureChangeForMode(this.internal.localTemperature); // initialize } } #handleSystemModeChange(newMode: Thermostat.SystemMode) { if (this.state.thermostatRunningMode !== undefined && newMode !== Thermostat.SystemMode.Auto) { this.agent.asLocalActor(() => { if (newMode === Thermostat.SystemMode.Off) { this.state.thermostatRunningMode = Thermostat.ThermostatRunningMode.Off; } else if (newMode === Thermostat.SystemMode.Heat) { this.state.thermostatRunningMode = Thermostat.ThermostatRunningMode.Heat; } else if (newMode === Thermostat.SystemMode.Cool) { this.state.thermostatRunningMode = Thermostat.ThermostatRunningMode.Cool; } }); } } #handleThermostatRunningModeChange(newRunningMode: Thermostat.ThermostatRunningMode) { if (this.state.piCoolingDemand !== undefined) { if ( newRunningMode === Thermostat.ThermostatRunningMode.Off || newRunningMode === Thermostat.ThermostatRunningMode.Heat ) { this.state.piCoolingDemand = 0; } } if (this.state.piHeatingDemand !== undefined) { if ( newRunningMode === Thermostat.ThermostatRunningMode.Off || newRunningMode === Thermostat.ThermostatRunningMode.Cool ) { this.state.piHeatingDemand = 0; } } } /** * Handles temperature changes to automatically adjust the system mode based on the current temperature * consideration. This logic is disabled by default and will be enabled by setting useAutomaticModeManagement to * true. */ #handleTemperatureChangeForMode(temperature: number | null) { if (temperature === null) { return; } const consideration = this.temperatureConsideration; switch (this.state.systemMode) { case Thermostat.SystemMode.Heat: switch (consideration) { case "belowTarget": this.adjustRunningMode(Thermostat.ThermostatRunningMode.Heat); break; default: this.adjustRunningMode(Thermostat.ThermostatRunningMode.Off); break; } break; case Thermostat.SystemMode.Cool: switch (consideration) { case "aboveTarget": this.adjustRunningMode(Thermostat.ThermostatRunningMode.Cool); break; default: this.adjustRunningMode(Thermostat.ThermostatRunningMode.Off); break; } break; case Thermostat.SystemMode.Auto: switch (consideration) { case "belowTarget": this.adjustRunningMode(Thermostat.ThermostatRunningMode.Heat); break; case "aboveTarget": this.adjustRunningMode(Thermostat.ThermostatRunningMode.Cool); break; default: this.adjustRunningMode(Thermostat.ThermostatRunningMode.Off); break; } break; } } #setupPresets() { if (!this.features.presets) { return; } this.reactTo(this.events.presets$AtomicChanging, this.#handlePresetsChanging); this.reactTo(this.events.presets$AtomicChanged, this.#handlePresetsChanged); this.reactTo(this.events.persistedPresets$Changing, this.#handlePresetsChanging); this.reactTo(this.events.persistedPresets$Changed, this.#handlePersistedPresetsChanged); this.reactTo(this.events.updatePresets, this.#updatePresets, { lock: true }); } /** Handles changes to the Presets attribute and ensures persistedPresets are updated accordingly */ #updatePresets(newPresets: Thermostat.Preset[]) { this.state.persistedPresets = newPresets; } /** * Handles "In-flight" validation of newly written Presets via atomic-write and does the required validations. */ #handlePresetsChanging(newPresets: Thermostat.Preset[], oldPresets: Thermostat.Preset[]) { if (newPresets.length > this.state.numberOfPresets) { throw new StatusResponse.ResourceExhaustedError( `Number of presets (${newPresets.length}) exceeds NumberOfPresets (${this.state.numberOfPresets})`, ); } const oldPresetsMap = new Map(); if (oldPresets !== undefined) { for (const preset of oldPresets) { if (preset.presetHandle !== null) { const presetHex = Bytes.toHex(preset.presetHandle); oldPresetsMap.set(presetHex, preset); } } } const persistedPresetsMap = new Map(); if (this.state.persistedPresets !== undefined) { for (const preset of this.state.persistedPresets) { if (preset.presetHandle === null) { throw new InternalError("Persisted preset is missing presetHandle, this should not happen"); } const presetHex = Bytes.toHex(preset.presetHandle); persistedPresetsMap.set(presetHex, preset); } } const presetTypeMap = new Map(); for (const type of this.state.presetTypes) { presetTypeMap.set(type.presetScenario, type); } const presetScenarioNames = new Map(); const presetScenarioCounts = new Map(); const newPresetsSet = new Set(); const newBuildInPresets = new Set(); for (const preset of newPresets) { if (preset.presetHandle !== null) { const presetHex = Bytes.toHex(preset.presetHandle); if (newPresetsSet.has(presetHex)) { throw new StatusResponse.ConstraintErrorError(`Duplicate presetHandle ${presetHex} in new Presets`); } if (this.state.persistedPresets !== undefined) { const persistedPreset = persistedPresetsMap.get(presetHex); if (persistedPreset === undefined) { throw new StatusResponse.NotFoundError( `Preset with presetHandle ${presetHex} does not exist in old Presets, cannot add new Presets with non-null presetHandle`, ); } if (preset.builtIn !== null && persistedPreset.builtIn !== preset.builtIn) { throw new StatusResponse.ConstraintErrorError( `Cannot change built-in status of preset with presetHandle ${presetHex}`, ); } } newPresetsSet.add(presetHex); } else if (preset.builtIn) { throw new StatusResponse.ConstraintErrorError(`Can not add a new built-in preset`); } const presetType = presetTypeMap.get(preset.presetScenario); if (presetType === undefined) { throw new StatusResponse.ConstraintErrorError( `No PresetType defined for scenario ${Thermostat.PresetScenario[preset.presetScenario]}`, ); } if (preset.name !== undefined) { const scenarioNames = presetScenarioNames.get(preset.presetScenario) ?? []; if (scenarioNames.includes(preset.name)) { throw new StatusResponse.ConstraintErrorError( `Duplicate preset name "${preset.name}" for scenario ${Thermostat.PresetScenario[preset.presetScenario]}`, ); } if (!presetType.presetTypeFeatures.supportsNames) { throw new StatusResponse.ConstraintErrorError( `Preset names are not supported for scenario ${Thermostat.PresetScenario[preset.presetScenario]}`, ); } scenarioNames.push(preset.name); presetScenarioNames.set(preset.presetScenario, scenarioNames); } const count = presetScenarioCounts.get(preset.presetScenario) ?? 0; if (count === presetType.numberOfPresets) { throw new StatusResponse.ResourceExhaustedError( `Number of presets (${count}) for scenario ${Thermostat.PresetScenario[preset.presetScenario]} exceeds allowed number (${presetType.numberOfPresets})`, ); } presetScenarioCounts.set(preset.presetScenario, count + 1); if (this.features.cooling) { if (preset.coolingSetpoint === undefined) { throw new StatusResponse.ConstraintErrorError( `Preset for scenario ${Thermostat.PresetScenario[preset.presetScenario]} is missing required coolingSetpoint`, ); } if ( preset.coolingSetpoint < this.coolSetpointMinimum || preset.coolingSetpoint > this.coolSetpointMaximum ) { throw new StatusResponse.ConstraintErrorError( `Preset coolingSetpoint (${preset.coolingSetpoint}) for scenario ${Thermostat.PresetScenario[preset.presetScenario]} is out of bounds [${this.coolSetpointMinimum}, ${this.coolSetpointMaximum}]`, ); } } if (this.features.heating) { if (preset.heatingSetpoint === undefined) { throw new StatusResponse.ConstraintErrorError( `Preset for scenario ${Thermostat.PresetScenario[preset.presetScenario]} is missing required heatingSetpoint`, ); } if ( preset.heatingSetpoint < this.heatSetpointMinimum || preset.heatingSetpoint > this.heatSetpointMaximum ) { throw new StatusResponse.ConstraintErrorError( `Preset heatingSetpoint (${preset.heatingSetpoint}) for scenario ${Thermostat.PresetScenario[preset.presetScenario]} is out of bounds [${this.heatSetpointMinimum}, ${this.heatSetpointMaximum}]`, ); } } if (preset.builtIn && preset.presetHandle !== null) { newBuildInPresets.add(Bytes.toHex(preset.presetHandle)); } } } /** * Handles additional validation of preset changes when all chunks were written in an atomic write operation. */ #handlePresetsChanged(newPresets: Thermostat.Preset[], oldPresets: Thermostat.Preset[]) { this.#handlePersistedPresetsChanged(newPresets, oldPresets); // Store old Presets for lookup convenience const oldPresetsMap = new Map(); const oldBuildInPresets = new Set(); if (oldPresets !== undefined) { for (const preset of oldPresets) { if (preset.presetHandle === null) { throw new InternalError("Old preset is missing presetHandle, this must not happen"); } const presetHex = Bytes.toHex(preset.presetHandle); oldPresetsMap.set(presetHex, preset); if (preset.builtIn) { oldBuildInPresets.add(presetHex); } } } for (const preset of newPresets) { if (preset.presetHandle === null) { if (preset.builtIn) { throw new StatusResponse.ConstraintErrorError( `Preset for scenario ${Thermostat.PresetScenario[preset.presetScenario]} is built-in and must have a non-null presetHandle`, ); } } } } /** * Handles additional validation and required value adjustments of persistedPresets changes when all chunks were * written in an atomic write. */ #handlePersistedPresetsChanged(newPresets: Thermostat.Preset[], oldPresets: Thermostat.Preset[]) { if (oldPresets === undefined) { logger.debug( "Old presets is undefined, skipping some checks. This should only happen on setup of the behavior.", ); } const entropy = this.endpoint.env.get(Entropy); let changed = false; const newPresetHandles = new Set(); for (const preset of newPresets) { if (preset.presetHandle === null) { logger.debug("Preset is missing presetHandle, generating a new one"); preset.presetHandle = entropy.randomBytes(16); changed = true; } newPresetHandles.add(Bytes.toHex(preset.presetHandle)); if (oldPresets === undefined) { if (preset.builtIn === null) { preset.builtIn = false; changed = true; } } else { if (preset.builtIn === null) { const oldPreset = oldPresets.find( p => p.presetHandle && preset.presetHandle && Bytes.areEqual(p.presetHandle, preset.presetHandle), ); if (oldPreset !== undefined) { preset.builtIn = oldPreset.builtIn; } else { preset.builtIn = false; } changed = true; } } } const newBuildInPresets = new Set(); for (const preset of newPresets) { if (preset.builtIn) { newBuildInPresets.add(Bytes.toHex(preset.presetHandle!)); } } const oldBuildInPresets = new Set(); if (oldPresets !== undefined) { for (const preset of oldPresets) { if (preset.builtIn) { oldBuildInPresets.add(Bytes.toHex(preset.presetHandle!)); } } } // Ensure built-in presets are not removed for (const oldBuiltInPreset of oldBuildInPresets) { if (!newBuildInPresets.has(oldBuiltInPreset)) { throw new StatusResponse.ConstraintErrorError( `Cannot remove built-in preset with presetHandle ${oldBuiltInPreset}`, ); } } /*if (this.features.matterScheduleConfiguration) { for (const schedule of this.state.schedules) { if (schedule.presetHandle && !newPresetHandles.has(Bytes.toHex(schedule.presetHandle))) { throw new StatusResponse.InvalidInStateError(`Schedule references non-existing presetHandle`); } } }*/ if ( this.state.activePresetHandle !== null && !newPresetHandles.has(Bytes.toHex(this.state.activePresetHandle)) ) { throw new StatusResponse.InvalidInStateError(`ActivePresetHandle references non-existing presetHandle`); } if (changed) { logger.debug("PresetHandles or BuiltIn flags were updated, updating persistedPresets"); this.state.persistedPresets = newPresets; } } override async [Symbol.asyncDispose]() { // Because we are basically the only user right now ensure the service is closed when we are disposed this.endpoint.env.close(AtomicWriteHandler); } /** Implementation of the atomic request handling */ override async atomicRequest(request: Thermostat.AtomicRequest): Promise { const atomicWriteHandler = this.endpoint.env.get(AtomicWriteHandler); const { requestType } = request; switch (requestType) { case Thermostat.RequestType.BeginWrite: return atomicWriteHandler.beginWrite(request, this.context, this.endpoint, this.type); case Thermostat.RequestType.CommitWrite: return await atomicWriteHandler.commitWrite( request, this.context, this.endpoint, this.type, this.state, ); case Thermostat.RequestType.RollbackWrite: return atomicWriteHandler.rollbackWrite(request, this.context, this.endpoint, this.type); } } } export namespace ThermostatBaseServer { export class State extends ThermostatBehaviorLogicBase.State { /** * Otherwise measured temperature in Matter format as uint16 with a factor of 100. A calibration offset is applied * additionally from localTemperatureCalibration if set. * Use this if you have an external temperature sensor that should be used for thermostat control instead of a * local temperature measurement cluster. */ externalMeasuredIndoorTemperature?: number; /** * Endpoint (Number or string-Id) where to find the indoor temperature measurement cluster to use as * local temperature measurement for the thermostat behavior. */ localIndoorTemperatureMeasurementEndpoint?: number | string; /** * Otherwise measured occupancy as boolean. * Use this if you have an external occupancy sensor that should be used for thermostat control instead of a * internal occupancy sensing cluster. */ externallyMeasuredOccupancy?: boolean; /** * Endpoint (Number or string-Id) where to find the occupancy sensing cluster to use as * local occupancy measurement for the thermostat behavior. */ localOccupancyMeasurementEndpoint?: number | string; /** * Use to enable the automatic mode management, implemented by this standard implementation. This is beyond * Matter specification! It reacts to temperature changes to adjust system running mode automatically. It also * requires the Auto feature to be enabled and the ThermostatRunningMode attribute to be present. */ useAutomaticModeManagement: boolean = false; /** * Persisted presets stored in the device, needed because the original "presets" is a virtual property */ persistedPresets?: Thermostat.Preset[]; /** * Implementation of the needed Preset attribute logic for Atomic Write handling. */ [Val.properties](endpoint: Endpoint, session: ValueSupervisor.Session) { // Only return remaining time if the attribute is defined in the endpoint const properties = {}; if ( (endpoint.behaviors.optionsFor(ThermostatBaseServer) as Record)?.presets !== undefined || (endpoint.behaviors.defaultsFor(ThermostatBaseServer) as Record)?.presets !== undefined ) { Object.defineProperty(properties, "presets", { /** * Getter will return a pending atomic write state when there is one, otherwise the stored value or * the default value. */ get(): Readonly { // When we have a pending value for this attribute, return that instead const pendingValue = endpoint.env .get(AtomicWriteHandler) .pendingValueForAttributeAndPeer( session, endpoint, ThermostatBaseServer, Thermostat.attributes.presets.id, ); if (pendingValue !== undefined) { return pendingValue as Thermostat.Preset[]; } let value = endpoint.stateOf(ThermostatBaseServer.id).persistedPresets; if (value === undefined) { value = (endpoint.behaviors.optionsFor(ThermostatBaseServer) as Record) ?.presets; } return (value ?? []) as Thermostat.Preset[]; }, /** * Setter will either emit an update event directly when in local actor context or command context, * otherwise it will go through the AtomicWriteHandler to ensure proper atomic write handling. */ set(value: Thermostat.Preset[]) { if (hasLocalActor(session) || ("command" in session && session.command)) { // Local set or command context bypass atomic write handling // We use this event to property apply state changes endpoint.eventsOf(ThermostatBaseServer.id).updatePresets!.emit(value); } else { endpoint.env .get(AtomicWriteHandler) .writeAttribute( session, endpoint, ThermostatBaseServer, Thermostat.attributes.presets.id, value, ); } }, }); } return properties; } } export class Events extends ThermostatBehaviorLogicBase.Events { externalMeasuredIndoorTemperature$Changed = Observable<[value: number, oldValue: number, context: ActionContext]>(); externallyMeasuredOccupancy$Changed = Observable<[value: boolean, oldValue: boolean, context: ActionContext]>(); persistedPresets$Changed = Observable<[value: Thermostat.Preset[], oldValue: Thermostat.Preset[], context: ActionContext]>(); persistedPresets$Changing = Observable<[value: Thermostat.Preset[], oldValue: Thermostat.Preset[], context: ActionContext]>(); /** * Custom event emitted when the calibrated temperature changes. */ calibratedTemperature$Changed = Observable<[value: number | null, oldValue: number | null, context: ActionContext]>(); /** * Custom event emitted when the Presets attribute is "virtually" changing as part of an atomic write operation. * Info: The events is currently needed to be a pure Observable to get errors thrown in the event handler be * reported back to the emitter. */ presets$AtomicChanging = Observable<[value: Thermostat.Preset[], oldValue: Thermostat.Preset[], context: ActionContext]>(); /** * Custom event emitted when the Presets attribute has "virtually" changed as part of an atomic write operation. * Info: The events is currently needed to be a pure Observable to get errors thrown in the event handler be * reported back to the emitter. */ presets$AtomicChanged = Observable<[value: Thermostat.Preset[], oldValue: Thermostat.Preset[], context: ActionContext]>(); /** * Custom event emitted to inform the behavior implementation of an update of the PersistedPresets attribute. */ updatePresets = Observable<[value: Thermostat.Preset[]]>(); } export class Internal { /** * Local temperature in Matter format as uint16 with a factor of 100. It is the same value as the one reported * in the localTemperature Attribute, but also present when the LocalTemperatureNotExposed feature is enabled. * Means all logic and calculations are always done with this value. * The value will be updated on initialization and when the localTemperature Attribute changes. */ localTemperature: number | null = null; /** * Storing fixed value internally to ensure it can not be modified. * This value will be initialized when the behavior is initialized and is static afterward. */ minSetpointDeadBand: number = 0; /** * Storing fixed value internally to ensure it can not be modified. * This value will be initialized when the behavior is initialized and is static afterward. */ controlSequenceOfOperation!: Thermostat.ControlSequenceOfOperation; } } // We had turned on some more features to provide a default implementation, but export the cluster with default // Features again. export class ThermostatServer extends ThermostatBaseServer.for(Thermostat) {}